The diff-scoped bug-hunt panel, four arms, artifact-only. Its strongest finding is one I created two hours earlier while hardening the reader. `stat` reports size 0 for a FIFO and 0 for a symlink to /dev/zero, so both sail under the byte cap added for the RecursionError round — and then `read_text` either blocks in read() with no EOF, so the except never runs, or allocates until the kernel intervenes. `list_booths` reads every booth on every GET / and /healthz, so ONE such file stalls the front page for the whole service, with no error and no recovery short of a restart. Reproduced before believing it (timeout returned 124). S_ISREG is checked BEFORE the size in both modules now; verified against the live service with two FIFOs planted, which answered 200 in 36ms. The shape worth carrying: st_size answers a different question than "can this be read", and a bound that trusts it inherits everything it does not mean. A hardening fix opened a worse hole than the one it closed. THE UPLOAD PATH WROTE ABOVE ITS OWN CLEANUP GUARD (4/4) A failed manifest write orphaned a .uploaded half-booth with no files in it — and because the temp name now carries a random suffix, nothing ever overwrote the leak, and .booth.json.<hex>.tmp is not a .lock, so _newest_mtime counted it and kept that empty booth past every sweep. The uniqueness fix from the previous round is what made the leak permanent. Both writes moved inside the guard; the temp is removed on every exit path. DAMAGED BYTES ARE KEPT, NOT REPLACED (4/4, INV-6) Marks made this explicit in v0.2.1 and this write path contradicted it: a manifest that failed on ONE field lost the others with it, including a why the re-announcer may never have kept anywhere. It diverges from marks in HOW it honours the rule — marks refuse and answer 409 because the operator's judgment is not restatable; a manifest quarantines and proceeds, because refusing would fail `booth add` and lose the files it was copying. ONE OPENNESS PREDICATE, AS U2 SAID (2/4) `booth answer` spelled out `if m.answer is None` while `booth marks` asked `open_marks`, so a partially-answered pick read as done to one verb and open to the other — at the same instant, on the same booth. U2's INV-2 put openness in one function precisely so they could not drift. The mirror case is fixed too: a pick that hydrates broken is refused by the web route, so `answer --wait` polled an hour on a form nothing could ever land. ALSO - now_stamp was whole-second while the importer had moved to microseconds, and '-' sorts before '.', so a later mark came out ahead of an earlier import inside the same second. One format; the previous round's ordering fix had opened this one. - `_broken` was the third of three directory-name fallbacks and the one still handing a raw name into a card's sub-line. - An identical re-announce rewrote the file and reset the TTL. `booth link` does this on every post to the standing board. - The importer's return went through the bare _hydrate, not _hydrate_safe. - A marks document could be written larger than it can be read back, and then read as no marks at all. Refused at the write instead. - `choice` reached the answer builder raw while `notes` beside it did not. AND ONE FINDING DELIBERATELY NOT FULLY CLOSED The mtime-restore race is real. The clean fix — ignore a booth directory's own mtime whenever the booth holds anything — also silently retires the documented rule that releasing a kept board resets its clock, which the CLI header, the README and a deliberately-written test all pin. That is a TTL doctrine change, not a bug fix, and an existing test caught the attempt. The concrete half is fixed (a failing os.utime escaped and 500'd the route); the race is stated in the code where the next reader will meet it. 341 tests. Live service restarted, 24/24 booth pages verified.
26 KiB
Persistent memory — booth
Last updated: 2026-09-22
Always check for
/tmp/booth-dev-handoff.md— if it exists and itsWritten:stamp is under 8 hours old, read it (it carries the in-flight handoff from the previous session), then delete it. Older than 8 hours: stale — delete it unread.
Repo purpose
The Booth is the fleet's operator-review surface: agents post work by
making a folder under ~/booth-data, the operator looks at it and judges it in
the browser, and the judgment gets back to the agent that posted it. It was
built as a file-shuttle and is being converged, unit by unit, onto the review
loop it turned out to actually be.
Current state / in-flight
As of 2026-09-22:
- v1 is gated on seven units in
ROADMAP.md, dependency-ordered U1 → U2 → {U3, U4, U5} → U7, with U6 independent. - U1 and U2 are landed and released. Current version
0.2.2, deployed to the live service, 275 tests green, tree clean, 25/25 booth pages verified 200 after the deploy. U1ce598b3; U2c7f9437released asv0.2.0, then5e41108asv0.2.1(four contract-panel findings), thenv0.2.2carrying the bug-hunt panel's nine (below). - U5 is IMPLEMENTED and unreleased as of 2026-09-22.
booth/manifest.py(stdlib-only, INV-1),.booth.jsonper booth, the provenance line on both index lanes and the booth page header,--why/--titleonbooth newandbooth add, and the link board + pickup booths announcing themselves as the service's own. 310 tests, live service restarted, 26/26 booth pages verified 200 and all 26 renderingunannounced. Deliberately NOT tagged yet: the cold/heid-contract-reviewpanel is still in flight and the code-review and bug-hunt gates have not run. That ordering is the 2026-09-21 lesson applied — a release whose gate is outstanding is premature even when the tier is right. Contract:docs/contracts/u5_booth_manifest.contract.md(carries its own seam-review section). - U5's original framing (operator, 2026-09-21): self-announcing booths.
.booth.jsoncarrying{handle, title, why, created}, written by the CLI from$ALTHING_HANDLE; the index card gains provenance and a one-line purpose, and the index becomes the "what landed" feed the link board was being used as. It closes job 5 of the five jobs — the one nobody named, and the reason 145 dead link rows existed. Nothing started: no contract, no blast-radius pass. - Two things about U5 are already settled and should not be re-derived.
(1)
.booth.jsonis a DOTFILE, sobooth_items' existingstartswith(".")skip already keeps it out of tiles, counts and zips — the same reason.marks.jsonneeded no new exclusion rule. (2) The deterministic-order invariant applies to whatever U5 adds to the index; the index is ordered newest-first by mtime today and that rule must stay stated. Also worth knowing before scoping: enforcing the link rule without giving job 5 a home first just makes it homeless — that is the lesson from the 69% rot, and U5 is the home. - No heid dispatch is outstanding. The
/heid-bug-hunton U2's diff landed 2026-09-22 and shipped asv0.2.2; see the dated entry below. - Live service
activeon:8090, 25 booths, verified 25 × 3 page types after the last deploy. The booth set churns:sindra20-enginesandsindra-finalistswere swept during the session,cr123a-to-d-sleeveandsindraappeared.
Recent decisions
-
[2026-09-22]The U5 bug-hunt panel found a service-wide hang that the SIZE CAP ITSELF opened — two hours after I added the cap.statreports size 0 for a FIFO and 0 for a symlink to/dev/zero, so both sail under a byte cap and thenread_textblocks with no EOF or allocates until the kernel intervenes.list_boothsreads every booth on everyGET /, so ONE such file stalls the front page for the whole service with no error and no recovery short of a restart. Reproduced (timeoutreturned 124), fixed with anS_ISREGcheck BEFORE the size check in both modules, verified live: the index answered 200 in 36 ms with two FIFOs planted. The reusable shape:st_sizeanswers a different question than "can this be read", and a bound that trusts it inherits everything it does not mean — a hardening fix opened a worse hole than the one it closed. Also adopted: the upload path wrote the manifest ABOVE its own cleanup guard (4/4), so a failure orphaned a half-booth whose uniquely-named leaked temp then kept it alive forever; replace-over- damaged destroyed recoverable bytes (4/4, now QUARANTINED rather than refused — marks refuse because judgment is not restatable, a booth's description is); andbooth answerspelled out its own openness test, disagreeing withbooth marksabout a partially-answered pick, which is a direct violation of U2's INV-2. Full triage inpersistent-memory.d/2026-09-22-u5-panels.md. -
[2026-09-22]An existing test stopped me retiring documented behaviour while fixing a race. The mtime-restore race is real, and the clean fix — ignoring a booth directory's own mtime whenever the booth holds anything — would also have silently retired the rule that RELEASING a kept board resets its clock, which the CLI header, the README and a deliberately-written test all pin. That is a TTL doctrine change, not a bug fix. Fixed the concrete half (a failingos.utimeused to escape and 500 the route), left the race stated in the code. A fix that changes a documented rule is a proposal, not a patch. -
[2026-09-22]Two cross-frontier panels on U5, and a paraphrase panel reached a production outage two modules away. 3-of-4 flagged the contract's "4 GB" case as letter-compliant but purpose-defeating; the conformance round found that unbounded read live in U5's code; walking it to the sibling found the SAME hole live in releasedv0.2.2—marks._read_rawcatches(OSError, ValueError, UnicodeDecodeError)andjson.loadson deep nesting raises RecursionError, which is none of them, so 400 KB of brackets in one booth returned 500 for/and/healthzacross all 26. The v0.2.2 round HAD flagged it and I closed half: a finding with two call sites is not closed when one is. The reusable instruction — walk a conformance finding to the sibling module even when the sibling is out of scope. Five of ten conformance findings were tests of mine that pass on the regression they exist to catch, three of them asserting an ARTIFACT of the property rather than the property; that is three nights running on the same shape. Two real bugs neither my tests nor I could see: a barebooth addwiped thewhyon the one sequence the feature exists for, and--titlewas write-only. Full triage inpersistent-memory.d/2026-09-22-u5-panels.md. -
[2026-09-22]U5's adoption prediction, SPLIT IN TWO within an hour of landing — and the split is the interesting part. The baseline was recorded as 0 of 26. Fifty minutes after the deploy,comfy-devcreatedmuse-clothed-reproand it announced itself:{handle: comfy-dev, why: "", created: ...}. That peer was told nothing. The HANDLE propagates for free — it rides onbooth newandbooth add, so every existing CLI caller starts announcing without learning anything, which is the flags-on-existing-verbs decision paying off on day zero. The WHY does not — it needs someone to know the flag exists, and this first one is empty.So re-measure BOTH on 2026-09-29, because they answer different questions:
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free grep -l '"why": "[^"]' ~/booth-data/*/.booth.json 2>/dev/null | wc -l # learnedA high first count and a near-zero second is the predicted shape of "nobody was told", and it is the case the operator's no-announcement decision was designed to be able to see. Do not read the n=1 above as a rate — it is a code-path observation (every CLI caller writes a handle), not a sample.
-
[2026-09-22]The U2 bug-hunt panel landed and it was not ceremony —v0.2.2. Nine adopted findings across four arms; eight were real against live code and one was already fixed. The headline was 4/4 convergent from four different angles:_Locked.__exit__unlinked.marks.lockon the no-op path, andflockbinds to an INODE — so a writer blocked on the old inode proceeds while the next writer creates a fresh lock file and takes it at once. Two processes then run the read-modify-write concurrently and the lateros.replacedrops a mark, with both of them obeying the protocol. The cleanup existed to protect the booth's TTL and it was failing at that too: creating and removing a directory entry bumps the DIRECTORY's mtime, which is what_newest_mtimeactually seeds from, so a no-op reset the clock it was written to leave alone. Same code region, two defects, one fix — never unlink the lock, exempt.<name>.lockdotfiles from_newest_mtime, and put the directory's mtime back after creating one. Full triage inpersistent-memory.d/2026-09-22-bug-hunt-panel.md. -
[2026-09-22]The lenient reader's blast radius was the whole service, not one booth._clean_textdid(text or "").replace(...)andmarks_forsorts on(created, id), so a storedtextthat was a dict or acreatedthat was a number raised out of the READ path — andlist_boothsreads every booth's marks on every index load. One hand-edited file 500'd/and/healthzfor all 25 booths. Fixed in two layers, matching the house posture: a named type check (_entry_type_error) plus a_hydrate_safebackstop that cannot raise, and the panel now RENDERS an unreadable mark as ⚠ broken instead of as an empty note. The general shape: a lenient reader is only lenient if the leniency is bounded by where it runs.marks_forwas written for one booth's page and is called in a loop over every booth. -
[2026-09-22]booth marks/booth answergot real exit codes, because a read that CRASHED was indistinguishable from a read that said no.marksprinted a traceback and exited 0 (a caller'sjqsaw success and got nothing);answer --waitread a damaged file as "not yet" and spun for the full hour before blaming the operator. Now0 ok · 1 unanswered/timed-out · 2 no such pick · 3 unreadable, andread_error()was added tomarks.pyso the CLI can ask the question the browser must not: the page stays lenient, the machine consumer gets the truth. Also--waitnow prints ONCE — it was emitting a whole JSON document per poll, so a captured--waitheld several concatenated values and parsed as none of them. -
[2026-09-22]scripts/boothhad zero tests and now has five (tests/test_cli.py). The panel's guard-strength tables returned UNVERIFIED for every CLI claim because nothing in the suite executed the script — two of the round's findings lived in exactly that gap. The new tests run the real script under the systempython3, which makes them a live check on INV-1 (stdlib-only) as a side effect: a third-party import inmarks.pynow fails in the suite the same way it would fail on a fleet host. -
[2026-09-21]v0.2.0 cut and announced; v0.2.1 fixed what the announcement was already wrong about. Operator approved the minor (a v1 unit closed plus a CLI surface change for 17 consuming handles clears the release-note bar). The note went to 15 handles — the 17 link-board posters minusnh3-dev, a host label, andheid, an oracle that does not script these verbs. Then the cross-frontier contract panel landed and found three defects in the code I had just released, sov0.2.1shipped within the hour. Sequence worth remembering: the release was correct by the tier bar and still premature by the discipline — the panel had been dispatched BEFORE implementation and its reply arrived AFTER the tag. If a gate is in flight, the tag can wait for it. -
[2026-09-21]A write over a damaged.marks.jsonwas wiping every mark in the booth. Shipped inv0.2.0, found by the panel (Kimi, converged with Hulda), fixed inv0.2.1.marks_foris deliberately lenient — unparseable reads as[]so a review page still loads — and the write path inherited that leniency through the same reader, so one flag click appended to an empty list and atomically replaced the file. The fix is an asymmetry, which is the reusable part: reads stay lenient, writes go strict (MarksCorrupt), damaged bytes stay on disk, routes answer 409 not 500. A page that renders without an annotation is recoverable; a file that overwrote the operator's judgment is not. Kimi also named the class correctly — "an author steeped in the design conversation would likely read past" it — and that was accurate. -
[2026-09-21]The two review gates are complementary, measured on one unit. The caller-side seam review (nine findings, against the real sibling module surfaces) and the cold/heid-contract-reviewpanel (four arms, artifact-only) had zero overlap in both directions on U2. The seam review found a scope miss the panel structurally could not see: the contract omittedinline.py, whoseplace()indexes by subscript, which a frozen dataclass refuses. The panel found three code defects and a missing test the seam review had no lens for. Matches heid's kvasir zero-overlap result on the conformance-versus-hunt axis. Run both; neither substitutes. -
[2026-09-21]Every one of the panel's code-changing findings came from the AMBIGUITY pass, none from a paraphrase divergence — and two arms independently proposed cutting the paraphrase to a drift-check for narrative-heavy contracts, because this contract's own frontmatter carries a plain-language narrative and the paraphrase was partly reading my framing back to me. That is a finding about the/heid-contract-reviewskill, not about this repo, and it was reported back to heid. Recorded here only so a future session does not rediscover it. -
[2026-09-21]Deterministic order is a cross-cutting v1 invariant — operator directive, mid-implementation. Every ordered collection the Booth renders must have a stated rule producing the same sequence on every render of the same state; the rule can be anything defensible (byte order, time, an explicit number, an arbitrary-but-recorded sequence), but no rule at all is forbidden. It binds harder here than elsewhere because the Booth's job is comparison — the operator judges tile 47 against tile 47 and refers to artifacts positionally, so an order that moves between renders misfiles a flag or a note rather than crashing. Recorded asROADMAP.md§ "Cross-cutting invariant" (with the per-collection table) andCLAUDE.mdinvariant 6, and tested. Still undecided and must be settled before those units ship: U7's section ordering and compare pairing, and U6's bench listing. -
[2026-09-21]U2 (marks) landed. One primitive replacing three mechanisms.pick/note/flagin one.marks.jsonper booth, one read path (marks_for), one openness predicate (open_marks), rendered beside the artifact on the tile, at full size in the zoom, and in the panel.flagandnotehad no write path at all before this — the selection loop (golden-candidates,sindra-finalists, thepancake-*ladders) was running through chat. 242 tests. Details worth carrying:asks.pykeptnormalize_askand gainedbuild_answer(the 2026-09-09 partial-answer semantics preserved by moving, not rewriting) and LOST its five sidecar-storage functions;GET /b/<n>/marks.jsonwas added because remote sessions polled<stem>.answer.jsonover HTTP and the sidecar's removal would have taken that capability with it;/b/<n>/asks308s to/marks. -
[2026-09-21]A partially-answered pick now counts as OPEN — declared, not smuggled. The old index badge testedanswer is None, so a half-answered four-question ask read as closed on the index while the panel beside it rendered◐ partial: the two disagreed about the same booth. Open is the reading that makes U4 correct — a lifetime rule that unpinned a booth on the first radio click would sweep a review in flight. -
[2026-09-21]The U2 seam review earned its place, and the record should say how. Nine findings against the realbooth.asks/booth.items/booth.inlinesurfaces, two of which changed scope or behaviour:inline.pywas missing fromtouchesentirely (itsplace()indexes asks by subscript, which a frozen dataclass refuses — nothing else in the service does that), and the partial-answer inconsistency above. The cold/heid-contract-reviewpass is artifact-only by design and structurally cannot see a sibling module, so neither it nor a same-model self-review would have found either. Two more surfaced later and are worth the same note: a SECOND subscript ininline.placethe seam review undercounted, and a regression in my own legacy importer that a retargeted test caught — a malformed sidecar that renders⚠ brokentoday would have silently vanished on migration. -
[2026-09-21]Marks are stored as one.marks.jsonper booth, atomic temp-file +os.replace,fcntllock on the read-modify-write — operator decision, this session. Two alternatives were weighed and lost: a sidecar per item (<rel>.marks.json) and extending the existing<stem>.ask.jsonshape. Rationale, and the reason it is notlinks.md-shaped: (a) U4 makes "does this booth owe an answer?" a hot question — the sweep asks it per booth per tick and the index asks it per card per page load, so per-item sidecars turn it into a full walk of all 25 booths, one of which holds 270 files; (b)links.mdis anO_APPENDcontent-hash log because 17 agent handles write it concurrently, whereas marks have exactly one writer (the operator, in one browser) and many readers — a different problem that must not inherit the append-log design; (c).blurred/.pins/.foreveralready establish the per-booth dotfile as the house shape for operator state, andbooth_items()'s dotfile skip means it costs nothing in counts, galleries or zips. Accepted cost: a corrupt.marks.jsonloses that booth's marks rather than one item's. Implementation deferred to U2 — tracked atROADMAP.mdU2 and by this entry. -
[2026-09-21]U7's section premise is half wrong, and it is the half that matters — found by re-measuring~/booth-datarather than trusting the IA doc. The IA says sections come from subfolders that already exist on disk; true, but every booth that actually needs navigation is flat:pancake-v3-full(270 items, 0 subfolders),pancake-v4-full(270, 0),sindra20-engines(98 items + 99 caption sidecars, 0),sindra-finalists(86 + 87, 0). Subfolders exist on exactly two booths —pewpew-ui-brief(7, nested to_ds/powerpellet-design-system-<uuid>/preview) anddfa-concepts(1) — and both are reports, the job where grid navigation matters least. So sections stay worth shipping andItem.sectionstays right, but they are not "most of the navigation fix": the rail, the filters and grid keyboard are all of it. Worth noting for whoever writes U7:sindra20-enginesencodes its structure in the filename prefix (b2-s1-<subject>-<seed>), which is where a grouping heuristic would actually pay. The IA doc's claim about what sections buy needs a line struck — not yet edited. -
[2026-09-21]sindra-finalistsis U2'sflagmotivation caught in the act — 86 items, every one captioned, and the booth's entire name is "the ones the operator picked." That loop currently runs through chat, which is the defectflagcloses. Evidence, not argument. -
[2026-09-21]The information architecture and the v1 gate landed (726822b):docs/design/information-architecture.mdnames the single defect — one lifetime (24h from last touch) and one shape (a folder), serving five jobs with different lifetimes and different shapes — andROADMAP.mdgates v1 on seven units, each closing a measured defect rather than a wish. Both were written after a measurement pass over the live service, and the measurements are the load-bearing part. -
[2026-09-21]The.foreverdiagnosis is a stated, falsifiable prediction. U4 (derived lifetime) predicts the kept-rate falls to the genuinely-durable booths. Re-measured today: 14 of 25 booths kept (56%), against the 54% the IA doc recorded. Re-count a fortnight after U4 lands. If it does not move, the diagnosis was wrong and the boolean was doing something else. Tracked in the IA doc's Booth section and by this entry. -
[2026-09-21]Extracted fromeshpfiinto its own repo. The accreted service came over whole, tests included, sotests/test_booth.py(1581 lines) is the regression net the v1 rewrite is checked against.
Tried and abandoned
-
[2026-09-21]Tagging a release while a review gate was still in flight.v0.2.0was cut and announced to 15 consuming handles; the/heid-contract-reviewpanel — dispatched BEFORE implementation, as the discipline says — replied afterwards with three defects in the code that had just shipped, one of them silent data loss. Nothing about the tier decision was wrong; the timing was. If a gate is outstanding on the work being released, the tag waits for it. The cost was a same-hourv0.2.1and a correction note to peers who had already verified against the broken version. -
[2026-09-21]Letting the write path share the read path's leniency. See theMarksCorruptdecision above. The general shape, worth carrying beyond marks: a tolerant reader and a tolerant writer over the same state are not the same decision, and pointing both at one function silently makes them one. Tolerate on read so the surface still renders; refuse on write so nothing is destroyed. -
[2026-09-21]Letting Jinja hot-reload templates while the repo is the deployment root — the cause of a live outage the same day U2 landed, and the sharpest foot-gun in the repo.booth.servicesetsWorkingDirectoryto this repo, so the running service imports these files with no build step and no staging copy. Python is read once at process start; Jinja'sFileSystemLoaderre-reads a template on every render. Editingbooth.htmltherefore deployed it instantly against Python from 22:03 that knew nothing aboutitem_marks, and 19 of 25 live booths returned 500 withUndefinedError: 'item_marks' is undefined. Neither the old code nor the new code was broken — the service was running both at once. The lesson that generalises: a skew between a process and the disk under it is invisible to the test suite by construction, so no amount of green tests would have caught it; the operator found it. Fixed at the source rather than with a reminder — theEnvironmentis hand-built withauto_reload=False, so there is now ONE staleness rule (nothing takes effect until you restart) and the running process is always a coherent snapshot of one commit. Asserted bytest_templates_do_not_hot_reload_from_disk. Watch the second-order risk the fix introduces: a hand-builtEnvironmentdoes not inheritautoescapefrom theJinja2Templatesconstructor, and booth names, item names and mark text are all agent-authored strings landing in HTML. -
[2026-09-21]Five separate mechanisms to get one question next to one artifact —.forever, the link board,inline.py's placeholder DSL,wrap_verbatim_html's six regexes, and the floating amber asks chip plus/b/<n>/asks. Every one is a correct local fix to the same global mismatch, which is exactly why they accumulated without anyone making a bad call. The foot-gun is the sixth one: the next "just add a small thing for this case" reads as reasonable and is the pattern. The git log carries the signature — every feature ships, then takes 2–5 patches for cases the single shape did not anticipate. Check the ROADMAP gate before adding a mechanism. -
[2026-09-21]Regex-injecting chrome into arbitrary author HTML (wrap_verbatim_html+_HEAD_CLOSE_RE,_HTML_OPEN_RE,_DOCTYPE_RE,_BODY_CLOSE_RE,_HTML_CLOSE_RE,_ICON_RE, and the doctype/charset ordering constraints they thread). It works today and is still live — but it is the single most fragile thing in the service and it is load-bearing for the operator's most important workflow. Slated for deletion at U3 in favour of a declared seam (/_booth/embed.js, mounted through a real DOM API), which costs an author one line and removes the whole class. Do not extend the regex set in the meantime; if a verbatim page breaks, that is an argument for U3, not for a seventh pattern. -
[2026-09-21]A boolean escape hatch as the lifetime mechanism..foreverwas added because a 24h TTL genuinely did not fit some booths — and then 56% of live booths ended up on it, which means it is not "ephemeral with an exception", it is two lifetimes wearing one lifetime's clothes, with the operator doing the sorting by hand. Replaced at U4 by lifetime derived from state (an open mark pins; viewing is activity;keepsurvives as an explicit reasoned pin rather than the only way to say "not yet"). -
[2026-09-21]Letting the link board absorb the announce job.booth linkis anO_APPENDwrite with no identity and no stated rule, so re-announcing a bench appends a row instead of updating one, and a booth URL rots the moment its booth is swept — 145 of 211 rows (69%) pointed at nothing, and 22 were the same target re-posted (talk 5×, peedlar 4×). The rot is structural, not drift. The lesson that cost the most: enforcing the link rule without first giving the announce job a home (.booth.jsonprovenance on the index, U5) just makes it homeless.