Heid bug hunt, hulda, second round on 92c774e:
- A lone surrogate was dropped at the final decode, after the leading-dot
rule had already run, so "\ud800.forever" came out as .forever, the
keep marker, and "\ud800.." as "..". The NUL and every unencodable
character now go first, in one pass, so nothing dropped later can shield
a dot. Starlette decodes a multipart filename strictly (utf-8, else
latin-1), so this was not reachable over HTTP; the helper is now right by
construction regardless.
- A suffix too long to keep was cut like text, and the cut could land on a
shorter suffix that means something: "….png" out of "….pngxxxx…"
became an image. A cut that changes classify/doc_kind now has its dots
neutralised.
- The 16-byte extension threshold was unguarded (every test suffix was 4
bytes); a .jpeg case pins it.
Falsifiers: tests/mutations/upload_names.toml, 7/7 proved. Not taken here,
as they sit in the upload route rather than this helper: the pickup-id
mkdir outside the try (a FileExistsError race), rmtree(ignore_errors)
hiding a failed cleanup, and a CancelledError skipping cleanup.
safe_upload_name let two names through that the filesystem cannot hold,
and each raised at open(): a 500 with the booth torn down. A NUL raised
ValueError, and a 200-character cap let 200 two-byte characters overrun
NAME_MAX (255 bytes, ENAMETOOLONG). The NUL is now removed first, so it
cannot shield a leading dot from the hide rule. The cap is 200 UTF-8
bytes, cut on a character boundary, and it comes out of the stem: the
extension is what classify reads, so a name that used to fit (80 CJK
characters) keeps its kind.
The NUL test posts a raw multipart body: httpx percent-escapes a NUL in
files=, so the server would see a literal %00 and the test would prove
nothing. Falsifiers in tests/mutations/upload_names.toml, 4/4 proved.
Found by design-dev's r3 heid bug hunt (hulda).
Compare's stages load their pictures through the catch-all file route, which
caught only OSError around resolve(); an embedded NUL raises ValueError. Same
class as resolve_booth's fix in f8d136a (heid bug hunt on the race fix,
hulda). The upload route's NUL-in-filename 500 is the same class and is left
to booth-dev: it is not on compare's path.
booth-dev's race note after the merge: the compare route resolved each side
in _compare_side and again in _compare_ring, then ring.index(a) raised if the
file vanished (or was relinked outside the booth) between the two; the review
did the same through cring.index(f). The compare ring is now built once and
the sides are judged by membership of it. The review re-judges its item and
scans forward for the next comparable one (usually one step, no longer a
resolve of the whole ring per render); an item no longer comparable renders
the review without a Compare control, and C does nothing.
The contract records the once-per-request rule and that the phone-width wrap
covers doc.html's bar too. r3.toml: 59 rows, four re-anchored.
The stale v1.1 line for compare pairing is corrected to the 2026-09-24
ruling (pairs are picked, never detected). persistent-memory records r3
live and unpushed, and the open race note for design-dev.
Navigation was built from the review ring while the compare GET also demands
containment, so an outside symlink (which stays in the ring) was offered by
the strip, the steps, the review's Compare control and the flag landing, and
404ed on arrival. Every one is now built from the compare ring (the review
ring filtered by the same conjunction, _in_booth).
Two pre-existing gaps compare inherits, fixed at the source: resolve_booth
caught only OSError, so a NUL in the booth segment was a 500; record_view
opened its marker blocking, so a planted FIFO hung every look. Plus: the page
treats %73ide=a as side=a, and the subgrid engine floor is stated. Two
findings refuted (a chorded click mid-drag never fires pointerup, measured;
booth_items never yields an unquotable rel). r3.toml: 57 rows.
The one drift: the separator was a border on B, making B's stage 1px
narrower than A's; it is now a 1px column gap, so the stages are the same
size to the pixel. Tests now read what the contract promises instead of a
proxy: the strip's ring order, the full bakeoff sequence, 1:1 and Fit by
geometry, the 900px break from both sides, A wrapping, each form naming its
own item, a sibling-prefix symlink, both reveals, the strip's flag, the back
arrow unlinked, a one-axis picture, a focused player, two videos with no
toggle. The contract names .cmp-cap, a press on a stage, INV-4's URL-driven
picker and the redirect branch's isinstance check. r3.toml gains ten rows.
GET /b/{name}/compare with the conjunction 404 (containment AND the review
ring), both sides recorded as seen, view state (side, link) mapped from a
closed set onto every link, side-keyed regions, and back=compare in
_mark_redirect. compare.html: two stages sharing one set of rows, the strip
as picker (the side active now), linked and per-side stepping, X/L/Z/A/B/C
keys under the review's guards, synced pan by fraction with an echo guard,
per-side blur reveals, JS-off parity.
The stage machinery moves out of view.html into _stage_js.html
(BoothMode.bind, BoothStage.attach), shared by the review and compare. The
review gains a Compare control and a C key. At phone width a full top bar
wraps.
Tables: r2c's 15 stage rows re-pointed to _stage_js.html; r2b's phone
top-bar row re-anchored (the wrap made it vacuous alone); new r3.toml. The
contract records the wrap, equal stages and C on the compare page.
Restores the contract as it stood at 1593ea2 (proposed a8428dc, booth-dev's
seam pass folded ebd7729/33d9175/05ad6c4, heid's contract panel folded
1593ea2). Those commits lived only in a work clone under /tmp, which the
2026-09-24 reboot wiped; the text is unchanged.
groa's late retry on the blur bug-hunt, adjudicated against the landed code.
Its four bugs were already fixed, but a robustness note (mkstemp's 0600 locks
out a reader under another uid, which then "sees nothing and replaces it")
pointed at a real gap. set_blurred built on read_blurred, the renderer's
lenient reader, which turns an unreadable, oversized or malformed
`.blurred.json` into an empty set. The writer then replaced the file, and
whatever it held was gone. This is the `.marks.json` wipe of 2026-09-21 in a
new module, and it shipped for a night.
- `_load` is the one parse with two postures. read_blurred maps its refusal to
"nothing blurred" (a damaged file costs the blur, never the page).
set_blurred lets it raise BlurUnwritable, which the route answers with 409
and the CLI with exit 3, and changes nothing.
- It refuses only for a REGULAR file it cannot read. A link, a directory or a
FIFO at either name holds no set anyone wrote, so it reads as empty, and the
postcondition judges whether the write can land: a link is replaced, a
directory refused.
- The file is 0644 again, as the line-format writer left it (fchmod after
mkstemp).
The open flags in `_read_capped` became a second layer behind the new lstat
check, and the mutation run caught their rows VACUOUS through the public API.
They are now held to account by direct tests, because they still close the
lstat-to-open race. blur_storage.toml: 25/25. No second panel was run: this
folds one reviewer note plus the repo's own recorded lesson, with a test and
a proved row for each behaviour.
design-dev's r2c round, merged on the operator's direct approval. It answers
his ask from 2026-09-23: fit and 1:1 modes, arrows at the image's edge rather
than the stage's, and click-and-pan in 1:1 with native image drag defeated.
- Fit fills the stage, up or down, with or without JS; 1:1 is natural pixels,
and every pixel is reachable. The operator ruled that Fit may enlarge.
- The toggle shows for every picture. The mode lives on <html> as `stage-one`,
set by the head script before the stage exists, so a 1:1 reel never flashes
Fit. It persists per viewer in localStorage (inside a try) and follows other
tabs.
- The arrows sit 8px outside the drawn picture, clamped inside the stage.
- 1:1 drag-to-pan: grab convention, a 4px threshold, pointer capture, and the
picture is not draggable.
Templates only (view.html, base.html); no server change. The two test changes
are declared in r2c_review_stage.contract.md: the r2b reveal test asserts "no
blur" (Fit keeps a drop shadow), and the r2_flow 360px-arrow row is retired
with successors in r2c.toml. Contract panel and both code panels 4/4.
- 1:1 start-aligns. The centred flex item overflowed both sides and the
start was unreachable; measured, a 3000px picture hid its leftmost
980px. Auto margins still centre a small picture.
- Drag lifecycle: a move with no button ends the drag, so a press
released outside the stage never pans on a later hover. Capture is now
load-bearing in a test. The threshold is 4px of total movement.
- A press on the stage's own scrollbar is never a pan. The arrows clamp
to the stage's client box, so they are never under a classic
scrollbar. The test runs a browser without --hide-scrollbars and
asserts the gutter exists.
- Stacked, the arrows' CSS spot is the stage's centre (30vh), set in
view.html because base.html lost to the page's later rule.
- The stage reveal is `hidden` until bound, and keeps Fit's drop shadow
when revealed. A blurred picture composes blur() drop-shadow().
- The mode follows another tab. A failed or unknown size returns the
arrows to their CSS spot.
- Tests: object-position, vertical centring, the Fit half of
aria-pressed, a storage read that throws, a large picture's toggle,
Fit forgetting 1:1, single-axis pan.
- Declared: the r2b reveal test reads "no blur" (the shadow stays), and
the r2_flow 360px-offset row is retired.
Mutation tables 137/137 across four. 810 passed.
The operator: "fit and 1:1 modes as well as moving the forward and back
arrows closer to the edge of the image ... mouse click and pan for 1:1
mode if it exceeds page width (defeat drag drop of image)". Ruled: "Fit
may enlarge."
- Fit: the picture's box is the stage's inner box, and object-fit: contain
draws it whole at the largest size that fits, up or down, never
cropped. It works with or without JS. 1:1 is natural pixels.
- The Fit | 1:1 toggle shows for every picture; the per-picture hide is
gone. It stays hidden without JS.
- The mode persists as `stage-one` on <html>, set by the head script
before the stage exists, so a 1:1 reel never paints a stage in Fit.
Anything stored but "one" reads as Fit. Storage never raises.
- The arrows sit wholly outside the DRAWN picture (near edge 8px),
clamped 8px inside the stage. They sit over the picture only when it
spans the stage, and never over the rail. They are re-placed on load,
resize, mode switch and 1:1 scroll, and keep their CSS spot until the
drawn box is known.
- 1:1 drag-to-pan when the picture overflows either axis: the picture
follows the pointer, a 4px threshold, pointer capture, grab/grabbing.
The picture is draggable=false. The stage's reveal button moves out of
the scrolled content to sit over the stage (a pan carried it off), so
no control is a pan source.
Contract docs/contracts/r2c_review_stage.contract.md (heid contract
panel 4/4 folded; it changed the no-flash mechanism). Declared test
changes: the Nyx stage-edge arrow test is replaced; the stage class and
the toggle's `hidden` are updated. tests/mutations/r2c.toml 16/16. 803
passed.
Two sessions' post-deploy sweeps on 2026-09-23 recorded a look at every booth,
which emptied "new since you looked" and collapsed the Desk's last section
into reverse name order. CLAUDE.md now says how to check the live service
without recording anything, and persistent-memory records the Desk ruling and
the three booths it hid.
Operator-approved 2026-09-23 ("fix it, one bigger thumbnail"), after his
report that sindra-nude-final looked "blurry until selected". c2b1454 sizes
thumbnails at 768 wide (the widest desktop tile, doubled for a 2x screen) and
caps them at 4096 tall. A browser test holds the number against the rendered
grid. c19d8c9 folds the heid bug-hunt (4/4 arms, five seat-executed probes):
cache hits must be regular files carrying the source's exact mtime, the cache
dirs never follow a link, the temp file is mkstemp, palette alpha and EXIF
orientation survive, and there is a 64 MP decode budget. 828 passed on the
branch; thumbs.toml 14/14.
Operator-ruled 2026-09-23 ("fix the blur"). 4cfbce5 is the fix: a JSON-array
blur set through stdlib-only booth/blur.py, shared by the service and `booth
blur`, plus Item.blurred_self so blur state has one reader. c1f5543 folds the
heid bug-hunt on it (hulda, regin, kimi). The format moves to its own name,
.blurred.json, because sniffing one file for two formats recreated the
wrong-item bug. The writer is judged by its reader, so a planted directory is
a 409 and not a 500. A lone surrogate is dropped, the writer respects the
reader's size cap, and the route and the CLI share one check_rel predicate.
853 passed on the branch; blur_storage.toml 20/20.
The heid bug-hunt panel on c2b1454 (4/4 arms, five seat-executed probes). The
new size rules governed only cache MISSES; the hit path trusted a name and an
mtime, inside a directory any fleet session can write into.
- A cache hit is a REGULAR file (lstat) carrying its source's EXACT mtime (4/4).
A planted directory at the cache path was returned as the thumbnail, and a
source replaced by `cp -p` or an archive extract kept an older stamp that
`>=` served forever. The encoder now stamps the thumbnail with the source's
mtime, so any change to the source is a miss.
- The cache directories are made component by component and never through a
link (seat P4). A `.thumbs` planted as a link put the cache outside the
booth, beyond the sweep. The booth-mtime restore now keys on creating
`.thumbs` itself.
- The temp file is mkstemp (4/4, seat P5). The old `<out>.<pid>.tmp` was
predictable, and a link planted there made the encoder overwrite its target
(600 B became 316,400 B).
- Palette transparency survives (3/4, seat-executed, and INTRODUCED by
c2b1454). The fits-but-heavy branch newly re-encoded palette PNGs, and
getbands() of mode P has no A even with tRNS.
- EXIF orientation is honoured for sizing and for the saved image (groa,
seat-verified). A camera portrait stored sideways was sized and tiled as a
landscape.
- A 64 MP decode budget (2/4). A header claims any size, and a failure is not
cached, so every request re-decoded it.
- The cache name carries the whole rule: width, height cap, quality and an
encoding version (groa). The width alone would have served stale bytes after
a quality change.
Declined: the utime-restore failing on a foreign-owned booth (booths are the
service user's), and regin's two solos (the THUMB_MAX export is not imported
anywhere; the live fixture is function-scoped). thumbs.toml: 14/14 proved.
The heid bug-hunt panel on 4cfbce5 (hulda, regin, kimi; groa timed out) found
four real defects in the round-trip fix, and three of its arms converged on the
worst: it re-created the bug it existed to fix.
- Two names, never a sniffed file (3/3). JSON went into the OLD `.blurred`, and
the reader guessed the format from the bytes, so a legacy file whose one line
is an item named `["a.png"]` read as {"a.png"} and blurred the neighbour. The
set now lives in `.blurred.json`, JSON only. The legacy `.blurred` is read as
lines only, and only while `.blurred.json` is absent; the first write retires
it, after the new file is in place.
- A planted directory is a 409, not a 500 (2/3 plus a third angle, executed by
the seat). The reader was hardened against it and the writer was not:
os.replace and unlink raised IsADirectoryError through the route. Now the
writer is judged by its reader: set_blurred re-reads after writing and raises
BlurUnwritable unless the set on disk is the set asked for. That one check
covers a directory at either name, a permission and a race.
- A lone surrogate is dropped on read (hulda, executed). `"\ud800"` is a valid
JSON string that no filename can produce, and the UTF-8 encode raised on it
at every later write.
- The writer respects the reader's size cap (2/3). Nothing capped the write,
and the reader reads an oversized file as EMPTY, which reveals everything.
- One predicate, check_rel, for the route and the CLI (2/3). The CLI's `*..*`
substring guard refused `a..b.png`, which the route accepts. It also refuses
an empty path now (regin, kimi), and every item is checked before any is
written.
- `booth blur` fails closed, with a message and exit 3, when its package is
missing (kimi), as `link` already does.
Declined, with reasons: the Item positional-constructor break (booth_items is
the only constructor, INV-1), the fdopen fd leak and the short read (not
constructible on a local filesystem, and the `.seen` shape), and
unreadable-reads-as-revealed (blur is cosmetic; the `.seen` posture).
blur_storage.toml: 20/20 proved. One row came back VACUOUS on its first run,
because `set() or X` is X, and was rewritten before counting.
The operator, on the live Desk: "how is this last activity first?" It was not,
usefully. The section sorted by `_newest_mtime`, which counts a look (`.viewed`),
so opening a booth moved it up. Tonight two post-deploy checks fetched every
booth page within half a second, which recorded 22 looks at once and collapsed
the section into reverse name order through the (mtime, name) tie-break.
Meanwhile each row shows "updated X ago", which is `landed_at`, a different
clock from the one the list was sorted by.
Operator ruling: "last activity can just be last time the booth was updated,
not necessarily operator's last activity." The section now sorts by
`(-landed_at, name)`, the date the row shows, labelled "last updated first".
Looking, flagging and blurring no longer move a booth. `list_booths` keeps its
own order for its other readers, and `_newest_mtime` still feeds lifetime.
The r2_flow contract (§3, the ordering table, INV-5) and ROADMAP's ordering row
are amended to match. Two tests and two r2_flow.toml rows cover it (25/25).
The operator on sindra-nude-final: "the images look blurry until they're
selected and blown up." The cap was 512px on the LONGEST side, which the
comment called "comfortably above any tile size", and it was, for a square. A
gallery tile is sized by its WIDTH, though, and a 704x1408 portrait got 256px
of width for a tile Chromium renders at 361 CSS px. That is 1.4x stretched at
1x density and 2.8x on a 2x screen. The review stage serves the original,
which is why it looked sharp once opened.
- THUMB_WIDTH = 768: the widest desktop tile (3 columns, 1440px and up,
measured at 321-361 CSS px across viewports) doubled for a 2x screen.
THUMB_HEIGHT_MAX = 4096 stops a long screenshot going through at full height.
- An original that fits the bounds is served as-is only when it is also light
(<= 64 KB; 768-wide thumbnails average 39 KB over the 381 live images) or
animated, since a thumbnail is one frame. Fitting a tile in pixels is not
being cheap in bytes: these portraits are ~1.1 MB PNGs.
- The size rule is in the cache name (`<rel>.768w.webp`). The live 512-cap
thumbnails are newer than their sources, so the mtime check alone would have
served them forever. The old files are orphans, swept with their booth.
- tests/test_thumbs_browser.py holds THUMB_WIDTH against the rendered grid at
1440, 1920 and 2560. The constant is a layout number, and a redesign that
widens the tiles turns it red instead of soft.
Measured cost, all 381 live images: 4.8 MB -> 14.2 MB of thumbnails, still ~27x
under the 386 MB of originals. Known limit: the 2-column (<=472px) and 1-column
(<=650px) reflows are softer than 768 covers at 2x. tests/mutations/thumbs.toml
proves 7 falsifiers.
The heid bug-hunt on r2b merge 1 found the /blur route stripping `f` before
writing, so the form for " a.png" blurred its neighbour "a.png". The route was
only half of it: `.blurred` was one stripped rel per line, so no writer could
store a rel with a leading space or a newline, whatever the route did.
Operator-ruled 2026-09-23 ("fix the blur").
- booth/blur.py (new, stdlib-only): read_blurred / set_blurred / BLUR_FILE.
`.blurred` is now a JSON array in sorted order, the `.seen` shape: opened
O_NOFOLLOW | O_NONBLOCK with an S_ISREG check and a 1 MiB cap, so a planted
symlink is refused and a FIFO can no longer hang every Desk render (the old
read_text() blocked on one). Writes go through mkstemp + os.replace. The
legacy line format is still READ, so the 6 live line-format files keep their
blur until their next write upgrades them. Measured before the change: 42
live rels, none with edge whitespace, so the defect had no live victims.
- The route no longer strips `f`.
- scripts/booth `blur`/`unblur` go through booth.blur.set_blurred instead of
their own grep/printf line writer. Two writers of one format is how the
formats drift, and after this change the shell writer would have appended a
line to a JSON array. Every path is checked before anything is written.
- Item.blurred_self (appended to the record): the item's own blur, resolved in
booth_items from the same read as `blurred`. It replaces build_gallery's
second read_blurred, which a write between the two reads could split
(invariant 3). app.py no longer reads blur state at all, and a test asserts
it.
Names stay importable from booth.app and booth.items (invariant 4). blur joins
test_stdlib_only. test_cli's per-item-survives test now reads through the reader
rather than asserting the old byte format. The r2b contract and its mutation
row follow blurred_self onto the record. tests/mutations/blur_storage.toml
proves 12 falsifiers by running the change each forbids.
Not in this change, and still ours: the "off"-means-ON idiom drift between
/blur, /blurbooth and /flag (forms only ever send 0/1), and the CLI's
`.blurbooth` touch following a symlink where the service no longer does.
design-dev's r2b merge 2 (D1 + D1b + D3), merged on the operator's approval
with both heid panels folded (code review and bug hunt, 4/4 each), landed after
merge 1 and its live check so a live regression points at one of the two.
436d234 is the feature. The Desk row gets an always-visible lifetime pill (kept,
held, counting), with zip / keep|release / wipe floating over the preview strip
on hover or focus and taking no room; on touch they are the row's last line.
Booth dates render on the row and the booth header from created_at (statx birth
time) and landed_at: four never-raise date filters in app.py, one `now` per
page, and a date the filesystem cannot give or the calendar cannot hold renders
nothing. The System / Light / Dark toggle is stored per viewer, applied before
first paint, and reaches the ask chrome embed.js mounts inside verbatim pages
(only the fragments it mounted; an author's own .bk-ask is never marked).
_svos_tokens.css is re-vendored at the same SVOS SHA with a scoping-only
transform.
1558a7f folds both panels.
design-dev's r2b merge 1 (D2 + D2b), merged on the operator's approval after
design-dev's "merge it" with both heid panels folded (code review and bug hunt,
4/4 each).
5ded5ff is the feature: a per-viewer "reveal all" for blurred items, and the
whole-booth fog control on the booth page, the review and the Desk. 75623c7
folds both panels, and two of its edits land in our code. set_booth_blurred no
longer touch()es through a planted .blurbooth symlink: anything already at the
name reads as fogged and nothing is written, otherwise it creates with
O_CREAT|O_EXCL|O_NOFOLLOW (the class record_view was hardened against).
booth_blur_all only redirects back to the review for a member of the review
ring, as the mark routes do.
20f1cb8 and ca0641f are test-only: opt-in Playwright traces for failing browser
tests, then a test browser with no internet in both fixtures, each with a
positive control (an external host fails fast, a Booth page still goes idle).
The flake's cause is NOT confirmed: 0 reds in 24 untraced runs after the change
is consistent with the fix but no trace ever caught the stalled request.
The bug hunt (4/4) and code review (4/4) were both clean on mechanism.
Their shared catch was the one-sided minute check.
Dates:
- The date filters never raise. One clock outside the calendar's range
500'd the Desk for every booth, because every row renders in one
response. An unrenderable date now renders nothing.
- "Updated" shows whenever it differs from "created" by a minute or more,
either way. Copied content is often older than its folder.
- A clock ahead of now shows its date, never "just now".
- A day is 24h ("1d ago" never appeared).
The row:
- The controls are last in the markup, so the booth's name comes first in
tab order and wipe last. The cluster is placed over the strip from the
row's box.
The theme:
- A choice made in one tab moves the Booth's other open tabs.
- The theme mark goes only on ask fragments the embed mounted.
Tests, strengthened after the code review:
- the pill is visible at rest;
- keyboard focus reveals the controls;
- the controls act with scripts off;
- Reveal all reaches the doc page;
- the high-contrast check reads tokens that actually differ;
- the art-light extras are written from SVOS, not derived from the
copies;
- two overstated mutation rows are replaced (one was a runtime no-op, one
went red through a syntax error).
Contract amended.
r2b.toml 55/55 proved. 799 passed.
Operator rulings, 2026-09-23.
D1, the Desk row:
- Kept vs ephemeral reads at a glance: an always-visible lifetime pill in
the right column (sage ★ kept, amber held, ◷ counting down).
- The facts line is facts only.
- zip / keep|release / wipe are one cluster, with zip out of the middle.
Where a real hover exists it floats over the preview strip (covering
pictures, never information), appears on hover or keyboard focus, and
takes no room. Anywhere else (touch, any coarse pointer) it is the
row's last line, visible, with 32px controls. × hides too (the operator
answered yes).
D1b: "created 12 Sep" (filesystem birth time; nothing when unknown) and
"updated 5d ago" (the content clock), as <time> facts on the row and in
the booth header, from one macro and one clock per page.
D3, the theme toggle: System · Light · Dark in the top bar.
- Stored in localStorage and applied in <head> before any stylesheet.
- System removes data-theme, so the OS query follows the OS live, with
no listener.
- The token sheet is re-vendored at the same SVOS SHA with a scoping-only
transform (155 declarations, the same set, both directions), so forced
themes win over the OS and high contrast follows the theme in effect.
- The ask chrome inside verbatim pages follows the choice through
data-bk-theme on our own fragments, live across tabs. The host page's
<html> is never touched.
Declared test changes:
- two row tests replaced;
- the wipe-dialog test hovers first;
- four r2_flow rows retired, with successors in r2b.toml (45/45).
785 passed.
Every Booth page asks fonts.googleapis.com for its faces, and
wait_until="networkidle" waits for that request. A stalled request to
Google therefore held a page until goto's 30s timeout. That is the
failure the full-suite flake shows: Page.goto timeouts in tests far apart
within one run. A stalled font request reproduces it exactly.
Whether that was THE cause is not proven:
- 23 traced runs went green, against 1 red in 8 untraced;
- no trace captured the pending request.
A test that depends on Google being reachable is wrong regardless.
Both browser fixtures now launch Chromium with every hostname but
127.0.0.1 failing DNS at once. Pages fall back to the system font stacks
the tokens declare. Positive control in each file: an external host fails
with ERR_NAME_NOT_RESOLVED in under 3s, and a Booth page still goes idle.
Mutation-proved (r2b.toml 28/28). 776 passed.
The browser tests flake under full-suite load only; every failing test
passes alone. BOOTH_TRACE=1 keeps a full trace (screenshots and DOM
snapshots) for each browser test that fails. BOOTH_TRACE=light keeps
actions and network only, because the full mode perturbs the timing it
watches: 0/8 red traced against 1/8 untraced on the same tree. Off by
default. Positive control: a deliberately failing test keeps a trace,
and a passing one keeps nothing.
Both panels ran 4/4 on 5ded5ff. They converged on the board and doc-page
gaps independently.
- A board holding files lost both blur controls (they sat inside the
board suppression meant for the one-click wipe), while its items'
"◉ booth" labels pointed at them. Only the wipe is board-suppressed now.
- A blurred doc's own full page rendered clear. Its body is blurred there
too, with its own reveal and a Reveal all to put the blur back.
- set_booth_blurred followed a planted .blurbooth symlink (`touch`), and
the new control made that a click away. Anything at the name already
reads as fogged; otherwise it is created O_CREAT|O_EXCL|O_NOFOLLOW.
- The fog landing echoed `back` unchecked into the 303. It is now built
from the review ring, as the mark routes do.
- The fog form is its own region, so an in-place save refreshes its
label. Reveal all stays outside every region: its state lives in the
tab.
- The review's Space-to-advance no longer swallows Space on a focused
button or link.
- Top-bar controls stay on one line at phone width.
- Tests tightened:
- method="post" on the fog forms;
- exact blur values;
- a storage READ that throws;
- an item's own reveal carried across a swap;
- reveal gated where it can act.
r2b.toml: 26/26 proved. 774 passed.
In-flight rewritten to what is actually live: design-dev's blur merge is HELD
at 5ded5ff awaiting his explicit 'merge it' ping (both panels dispatched 17:53,
unfolded), the redesign and thumbnails and dates are shipped, and the browser
suite is flaky under load and NOT fixed.
Two detail files added. The dates one is the reusable lesson: three plausible
proxies for a creation date were considered and one was nearly built, and the
real answer was a syscall away — the system already recorded what looked
unavailable. One of the rejected proxies was write-on-read, a shape this repo
had finished paying for hours earlier.
The flake entry is written as OPEN with its limits stated: three tests, two
real defects fixed, neither proven causal, and n=3 cannot show an improvement.
Recent decisions and Tried and abandoned preserved intact (49->51 by addition,
7 unchanged); the index is back under the soft cap at 141 lines from 285, all
of the reduction from settled history leaving the volatile section.
The operator ruled blur A, and made it urgent: "per booth blurring is now
important since we are showing up to 4 images."
- Reveal all: one control per booth, in the booth header and the review's
top bar, outside every data-region. It is in the markup only when
something is blurred, always `hidden` until the script shows it.
- The state is sessionStorage per booth, per tab, and nothing reaches
the server. It is carried as one `reveal-all` class on <html>, applied
before first paint from the page's own data-booth, so booth A's reveal
cannot follow you into booth B and the index is never revealed.
- Per-item reveal buttons stand down by stylesheet, and an item's own
reveal is never touched, so "blur again" restores each item as it was.
- A storage write that throws still applies the click.
- The booth blur control: a plain form to booth-dev's POST /blurbooth, so
it works with scripts off. Its label follows is_booth_blurred; from the
review it carries `back` and lands on the same item. A fogged booth's
Desk row says "◉ blurred".
- Found by rendering it: under a fogged booth every item reported
`blurred`, so an item blurred only by the booth offered an un-blur that
visibly did nothing. The gallery now carries `blurred_self`, and such an
item shows "◉ booth", a label rather than a control.
Contract docs/contracts/r2b_desk_reveal_theme.contract.md (heid contract
panel 4/4, folded). tests/mutations/r2b.toml: 14/14 proved. 765 passed.
The operator: "I think I want creation and update dates on the booths now too."
UPDATE was already there — `landed_at`, the newest mtime among CONTENT
excluding our own machinery, which the Desk already sorts "new since you looked"
by.
CREATION had no honest source. `.booth.json` carries a declared `created`, but
only for booths posted through the CLI since U5 — TWELVE OF THIRTY live booths
had none. Every alternative was a guess wearing a fact's clothes: oldest content
mtime is wrong the moment an agent copies files with timestamps preserved;
directory mtime is just "last thing added", which is landed_at renamed; and
stamping a first-seen marker on read is the same write-on-read shape that spent
an hour of today aging the booth it cached.
ext4 records a real birth time. CPython does not expose st_birthtime on Linux,
so booth/birthtime.py reads it through statx(2) — a fact the disk already holds
rather than one we invent. Verified against stat(1) on live booths, 6 of 6
exact, including every booth with no manifest. ONE rule for all thirty, which is
what invariant 6 asks of anything statable in a line.
None when the filesystem cannot say (tmpfs, NFS, an old kernel), and None
renders as nothing — the honest output when nobody knows. Never raises:
list_booths calls it once per booth on every index load, so a read that can
raise is a service-wide outage wearing a single-booth bug's clothes.
ALSO TWO REAL TEST-HARNESS DEFECTS, found chasing a flake and fixed on their
merits rather than because they were proven to be the cause:
- The keyboard-flag browser test fired ArrowRight and `f` back to back,
assuming the first had finished — and focus() does a scrollIntoView, so under
load `f` could arrive with no cursor and flag nothing. It now waits for the
cursor to land.
- BOTH browser fixtures did bind -> getsockname -> CLOSE -> hand uvicorn the
port NUMBER, leaving a window for the kernel to give that port to somebody
else. This suite runs two browser files that each start a server per test, so
the competitor is right there. The bound socket is now handed over directly.
⚠ THE FLAKE IS NOT PROVEN FIXED. Two different browser tests failed once each
across full-suite runs while passing 3/3 and 5/5 in isolation; since the fixes,
one failure in three runs. n=3 cannot distinguish that from the prior rate and
this commit does not claim it does.
770 green on a clean run.
A fresh session needs four things that are not derivable from the code: that
design-dev is shipping in two merges with blur first, that the booth-blur
storage and CLI are already landed so only the control is missing, that the Desk
exposes 84 images across 22 booths on the first page (which is WHY blur got
re-prioritised), and that the operator explicitly declined to have the
sindra-nude-* booths blurred on his behalf.
Also records that the hover ruling only looked like it reversed design-dev's
argument — he resolved it with @media (hover: hover) rather than anyone being
overruled, so it should not be re-raised as a conflict.
The operator: "per booth blurring is now important since we are showing up to 4
images."
The Desk is why. Measured on the live set: 84 images across 22 booths on the
page he opens first, 10 of them blurred. Before the redesign the index showed
one cover per booth; four-up multiplies the exposure by four, and NOTHING POSTED
BEFORE THE REDESIGN OPTED INTO THAT.
The storage landed with the flag; this is the half that makes it usable before
design-dev's control ships. Seventeen handles call this script, so a session
posting sensitive work can self-blur AT POST TIME — which is the durable fix,
because the operator should not have to police 22 booths by hand.
No files named means the whole booth, which is the mental model already: `blur
<name> <file>...` was per item and required two arguments, so one argument could
only ever have been an error. COMPOSES with the per-item list: `unblur <name>`
clears the flag and leaves individual choices exactly as they were, the same
promise the resolver makes.
Also records both rulings routed this turn: x hides with the other Desk
controls, and the theme toggle reaches the chrome inside verbatim pages.
Verified under the system python3 with no venv, which is the only way most
callers ever run it.
The operator ruled booth-level blur in and chose reading A for the reveal
("A is fine"). design-dev specced the semantics and owns the controls; this is
the storage half.
COMPOSES, NEVER OVERRIDES. An item is blurred iff the booth is blurred OR it is
in .blurred, so turning booth blur off leaves an agent's per-item choice exactly
as the poster left it. An override would need a per-item "unblurred" exception
list, which is state nobody can see.
Resolved in booth_items, so every surface inherits it for free — Desk strip,
tiles, flag tray, filmstrip, stage all already read Item.blurred and none of
them learns the booth flag exists (INV-1). Images and video only; audio has
nothing to hide from a glance.
A MARKER, deliberately not JSON. `.seen` is JSON because it holds rels that must
round-trip exactly; a boolean has nothing to round-trip, and matching `.forever`
means the two whole-booth flags read the same way. We told design-dev it would
be JSON and it should not be — said so rather than quietly shipping the other
thing.
is_booth_blurred mirrors is_kept's lstat shape WITH THE SAFETY INVERTED, and the
inversion is the point: is_kept fails toward keeping because a failed read must
not authorise a delete; this fails toward HIDING, because a failed read must not
reveal something a poster asked to fog. Both are "the failure does not cause the
loss".
Also records the operator's 2026-09-23 ruling that there is NO 1.0 yet, and adds
.blurbooth to CLAUDE.md's dotfile list. 766 green.
Found by design-dev, the same class as the wipe dialog he had just fixed on the
Desk, and reported across the fence rather than kept.
A board row's description and URL are written by any of seventeen agent handles
and were pasted RAW into the `confirm()` the operator reads before approving a
delete. A bidi override (U+202E) or a newline in either re-orders or hides what
he is consenting to, so the row shown is not the row removed.
Escaping does nothing here and that is the trap: autoescape protects the PAGE,
but `confirm` renders a plain string, so the markup defence everyone reaches for
first is irrelevant to the surface that actually carries the decision.
Control and bidi formatting characters now render as U+FFFD — visibly mangled,
never silently re-ordered — through the same helper shape design-dev used, so
the two dialogs cannot drift apart.
Both arguments go through it, and the mutation row defeats exactly that: taking
the raw description back for one of the two turns the test red. 763 green.
The operator: "release and x take up space whether or not they're visible."
Confirmed — opacity:0 hid them while still reserving about 100px of side column
and a 36px row. Each control now sits beside the fact it changes ("kept ·
release", "expires in 22h · keep"), always visible, taking no room of its own,
and nothing hides behind a hover that touch screens never had.
d40e8fd is the change; 704e8cd is its heid bug-hunt fold (round Slate):
coarse-pointer touch targets at 28px with wipe clear of zip, control and bidi
characters shown as U+FFFD in the wipe dialog, an unknown data-confirm word
prompting rather than submitting unguarded, and the CSS "code" rule wrapping
anywhere so a long unbreakable install path in the footer stops widening every
page, the Desk included.
A surgical change that still went through a bug-hunt, which is the discipline
paying for itself: the last item was a latent overflow already on main that only
became visible once the row was a flex container.
- Touch: on a coarse pointer every row control is at least 28px square
again (32px), and wipe stands clear of the zip link. The move onto the
facts line had dropped the deliberate 28px floor to ~21px, 4-6px from
zip; with scripts off no confirm fires, so a mis-tap on wipe is the
delete. The zip link no longer breaks between its glyph and its word,
and each separator is glued to the item after it.
- The wipe dialog shows the name as it should be read: control and bidi
formatting characters in an agent-made name show as U+FFFD, so U+202E
or a newline cannot rewrite what the operator approves. An unknown
data-confirm word now prompts generically instead of submitting
unguarded (fail closed).
- No page scrolls sideways: `code` wraps anywhere, so a long unbreakable
install path in the footer or the empty Desk no longer widens every
page. The overflow test now sweeps 390/720/850/1000/1400 with the
heaviest row the Desk draws, and compares scrollWidth with the page's
own clientWidth.
Its first fixture used a hyphenated path, which wrapped by itself; the
test passed with the bug present until the path became one unbreakable
run. r2_flow.toml: 27/27 proved. 749 passed.
Two lessons from the thumbnail work, the second of which nearly shipped.
We parked progressive loading on a count of images and the cost was in bytes.
'Measure the real booth before optimising it' was followed and still gave the
wrong answer, because we measured the dimension that was easy to measure rather
than the one the user feels.
And a cache living inside the thing it describes can age that thing. Excluding
every path under the cache dir passed its own test and was still wrong: creating
the directory touches the BOOTH's own mtime, which is what _newest_mtime seeds
from. The contents were excluded; the existence was the leak. Had it reached the
Desk, one index load would have pushed every booth's expiry out and the TTL
would never have fired again.
Operator, on the live Desk: "release and x take up space whether or not
they're visible." They sat in a side column at opacity 0, which hides a
control and still reserves its box, and hover-only never worked on
touch.
Each control now sits on the facts line beside the state it changes:
release after "kept", keep after a countdown or hold, wipe last. They
are always visible and quiet, and wipe turns danger only under the
pointer or focus. The side column renders only when the row carries a
badge. The row is flex, so an absent column costs no gap. Forms, POST
targets and data-confirm wording are unchanged.
The flex row exposed a latent sizing bug: the stacked Desk column was a
bare 1fr, whose minimum is its content's, so a long nowrap provenance
line scrolled the page sideways at phone width (1029px at 390). It is
now minmax(0,1fr).
Both behaviours have browser tests, mutation-proved (r2_flow.toml:
21/21). Contract C4 amended.
design-dev's test read 'the originals shown small (no generated thumbnail)',
which was true when written and is precisely what the operator rejected: four
images per booth on the page he opens first was the heaviest surface in the
service.
Declared rather than quietly edited, per the rule that an existing assertion is
not changed to make a change pass. The behaviour genuinely changed, on his own
instruction to swap all four small surfaces in one commit.
Worth recording in the docstring: the URL carries ?thumb=1 from the EXTENSION
alone, with no disk read, so a tiny stub fixture still gets the parameter and
the route serves the original when there is nothing worth generating. The URL
never depends on what is on disk.
39/39 falsifiers proved across both mutation tables.
design-dev's two follow-up commits on the R2 branch.
167f265 is PRE-EXISTING and his to have found, not his to have caused:
Path.is_file() swallows ENOENT but PROPAGATES EACCES, so one folder with r--
and no x in one booth made booth_items raise — and list_booths calls it for
every booth, so the index 500s for all of them. Identical blast radius to the
0xff filename the bug-hunt panel found, arriving through a different syscall.
39a3cb2 commits R2's own falsifiers as tests/mutations/r2_flow.toml, 18 rows.
Its first run caught three vacuous proofs, which is the fourth time this week
that running the mutation has disagreed with reading the assertion.
# Conflicts:
# booth/items.py
Two corrections to the thumbnail work, the first of them a live bug shipped an
hour ago and caught by design-dev before its worst form landed.
⚠ GENERATING A THUMBNAIL RESET THE BOOTH'S EXPIRY CLOCK. `_newest_mtime`
excludes `.lock` sidecars because machinery is not the operator doing something;
the thumbnail cache is machinery too, and it is written by the SERVER on a mere
view. Excluding the cache's CONTENTS turned out not to be enough — creating
`.thumbs/` touches the BOOTH DIRECTORY's own mtime, which is exactly what
_newest_mtime seeds from. The booth's stamp is now restored across the mkdir,
which cannot hide real activity because any file an agent adds is counted by its
own mtime in the same walk.
The failure this prevents is not small. Once the Desk's preview strip pulls a
thumbnail per booth, ONE INDEX LOAD would have pushed every booth's expiry out
and the TTL would never have fired again — nothing would ever sweep. It was
already live for the gallery, one booth at a time.
TWO MORE SURFACES, because the fix only helped where it was wired:
Desk preview strip four small images per booth on the page he opens FIRST.
design-dev measured 28 originals / 24.1 MB on a 12-booth
copy; live has 28. The heaviest surface in the service,
heavier than the gallery it previews.
flag tray _marks.html rendered originals as tray thumbnails.
The review stage stays on the original, because that is the full-size review.
754 green plus the new guards.
The operator found this in about a minute of using the live Desk: "images load
at full resolution instead of calculated thumbnails, which means they load VERY
slowly and are tiny."
MEASURED on the live set:
sindra-corpus-v1 66 images 77.5 MB 1024x1024 each
sindra-sfw-pool 59 images 71.7 MB
sindra 30 images 61.6 MB 2.1 MB average
sindra-bakeoff 40 images 57.2 MB
A tile renders around 250px, so the grid shipped roughly 16x the pixels that
reach the screen.
⚠ OUR PARKING RATIONALE WAS WRONG IN AN INSTRUCTIVE WAY. ROADMAP parked
progressive loading on "the largest gallery is 66 images; at that size a lazy
grid is almost certainly fine", and the parking-lot row said "270 <img
loading=lazy> may be fine". Both count IMAGES. Neither weighs BYTES. We measured
the dimension that was easy to measure rather than the one that determines the
experience, and 66 really is a fine count sitting on a terrible payload.
booth/thumbs.py caches WebP at 512px longest side inside the booth at
`.thumbs/<rel>.webp` — inside on purpose, so a cache can never outlive what it
describes. Pillow is an optional import: absent, every tile falls back to the
original, so the page is heavier and never broken. Generation is lazy, atomic
(temp + os.replace), rebuilt when the source is newer, and NEVER RAISES.
?thumb=1 rides the EXISTING file route rather than growing a new one, because
that route's traversal guard is already correct and a second route is a second
place to get it wrong.
ALSO FIXES A PRE-EXISTING LEAK THE CACHE WOULD HAVE WALKED INTO. booth_items and
zip_booth both tested `p.name.startswith(".")` — the FILE's name — so
`.thumbs/a.png` (name `a.png`) would have rendered as a gallery item and shipped
inside every zip. CLAUDE.md invariant 2 promises a dotfile costs nothing in item
counts, galleries or zips; that was true only at the top level. Both now skip
every dot-prefixed path COMPONENT.
AND THE FILMSTRIP, which is the same defect in a worse place: it shows EVERY
ring item at a few dozen pixels, so full-resolution frames there cost more than
the grid did. The stage is untouched and stays full size, because that is the
full-size review.
Item.thumb is derived in the resolver, not by a template reasoning about `kind`
(INV-1). build_gallery had to carry it too — a missing key there rendered as a
SILENT fallback to the full image, which is exactly where a new Item field gets
dropped with nothing failing.
754 green.
tests/mutations/r2_flow.toml: 18 falsifiers, each proved RED under its
change by scripts/mutation_check.py (18/18). Its first run found three
vacuous proofs, now resolved:
- landed_at's per-entry skip: the symlink-loop fixture stopped raising
once the clock moved to lstat. New fixture: a folder that lists but
cannot be searched.
- the Desk's bench URL guard: the test covered bookmarks only. A
hand-edited registry bench now rides with it.
- flagged_targets' `error is None`: defence in depth (hydration already
strips a damaged mark's target), so no single-guard row; named in the
table header instead.
The rail's flagged filter and the orphan-flag list read flagged_targets
rather than restating it; no reachable behaviour changes.
Path.is_file() swallows a missing entry but propagates EACCES. A
directory with read and no execute permission lists its names while
every stat under it raises, so one such folder in one booth raised out
of booth_items — and list_booths calls that for every booth, taking the
index down for all of them. The same blast radius as the
unrepresentable-filename case; the same posture applies: such an entry
is not a renderable file.
Predates R2 (identical on main before the merge); found while folding
R2's bug-hunt, where it made landed_at's per-entry skip unreachable.