In-place client (base.html): - Saves are serialized: POST, re-fetch and swap complete before the next save starts, so an older snapshot can no longer land after a newer one. - A form already queued or in flight ignores another submit; a double-click writes one note. - Dirty controls (drafts, unsent radio choices) and disclosures carry by identity (form action + hidden ask/target/mark/f + name), not position. - Any non-tile structural difference, or a page with no region to swap, reloads instead of patching. Server and templates: - .seen is a JSON array read without following links or blocking, regular files of at most 1 MiB only; malformed, nested-too-deep or planted markers read as nothing seen. - landed_at reads symlinks by lstat and skips one unreadable entry instead of pinning the booth in "new". - The Desk counts flags on current items only; orphan flags are listed under the tray with an unmark form. - Agent-written bench and bookmark URLs link only when http(s). - Audio and video tiles carry a review link. - A rel the filesystem cannot represent is a 404, not a 500. - A non-finite Accept q-value fails to parse. - The standalone marks page has regions and updates in place. - The review's next arrow sits at the edge at phone width. Contract amended for each, plus an accepted-risks section (unlocked .seen read-modify-write, a planted .viewed symlink, Item.ordinal with no default). 741 passed. Each new browser test was mutation-checked against its fix; the serialization test forces the race with a held first refresh, since localhost alone never lost it.
32 KiB
contract_version, status, module, purpose, depends_on, language, complexity, estimated_loc, confidence, used_by, touches, assumptions, open_questions
| contract_version | status | module | purpose | depends_on | language | complexity | estimated_loc | confidence | used_by | touches | assumptions | open_questions | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0.2-BUILT | BUILT 2026-09-23 on design-dev/svos-retheme (C1-C7, TDD), awaiting heid code-review and bug-hunt before the hand-over to booth-dev. PROPOSED 2026-09-23 by design-dev. Ruled by the operator the same day in the `flow` mark on booth-flow-concepts (direction a_b; compare MODE to be built in this arc; voice plain; emblem no), relayed via Miranda → booth-dev, verbatim at docs/rulings/. Compare mode is NOT in this contract: it lands after this one as r3, as a view toggle over the same item record. | booth.app + booth.items + templates (the review flow) | Make the Booth a place where judgment happens rather than a place where files are shown. The operator's bar is 'did anything change when I opened it'. A reskin cannot clear that bar; this contract changes the flow. There are three surfaces and one plumbing change. THE DESK: the index triaged by what needs the operator. THE LIGHTBOX: a booth page with the set on the left and the verdict beside it. THE REVIEW: full size with the judgment on screen, a filmstrip, and seen-tracking. The plumbing is IN-PLACE JUDGMENT: a mark POST that does not reload the page or eject you from full size. |
|
python + jinja + a little javascript | high | 900 | 0.6 |
|
|
|
|
R2 — the review flow: the Desk, the lightbox, the review
The requirements this answers (from the round-2 README, uncorrected by the operator)
| # | requirement | answered by |
|---|---|---|
| 1 | show me what needs me | the Desk's needs you section |
| 2 | picking winners is the main judgment | the lightbox's flag tray; F in the review |
| 3 | flag without losing my place | in-place judgment + back=view |
| 4 | position is identity (within the set as it is now — not a durable id) | Item.ordinal, printed on every tile |
| 5 | the question stays beside the work | the verdict aside (sticky) |
| 6 | reports keep their author's layout | verbatim path untouched |
| 7 | listening sets are real | review_chain includes audio and video |
| 8 | lifetime is not an organising principle (it is still SHOWN as a fact on each row; it no longer GROUPS or SORTS) | the Desk drops the kept/ephemeral lanes |
Terms used below
- Reticle: the SVOS selection mark in base.html — four corner brackets drawn inside a box. It marks the one current or selected thing and nothing else.
- Tape: a row of small segments, one per item in the review ring, each showing seen, flagged or current.
- Stage: the area of the review page where the artifact itself renders.
- All Booth state files are dotfiles. That covers
.marks.json(MARKS_FILE),.viewed,.blurred,.seen,.forever,.pins,.booth.jsonand every*.lock. "Non-dot entries" means the posted content and nothing the Booth or the operator wrote.
Components
C1 — Item.ordinal (items.py)
ordinal: int is the item's 1-based position in booth_items(booth), i.e. in
sorted(rel) order over all items. It is assigned in the resolver loop, so
no route derives it.
-
ordinalis appended as the dataclass's last field, never inserted. Mid-dataclass insertion is a positional-construction break, andgrouphas already had that conversation. -
The resolver's
quote()guard on non-UTF-8 names stays exactly as it is. It looks like a straytryaround a discarded result, but it is what keeps one 0xff filename from taking down the index for every booth. -
An item skipped by that guard takes no ordinal, so ordinals stay contiguous over the items that render.
-
A filter never renumbers. Under
?filter=flaggeda tile still shows the number it has in the whole set. That is the point: "#07" is a property of the item, not of the view. -
A new file renumbers everything after it. That is honest, and it matches the order: the operator's positional references are to the set as it is now.
C2 — review_chain and .seen (items.py, app.py)
review_chain(items): the rels of items whose kind is image, video or audio, in item order. ONE LINE: the item order filtered to media. It replacesimage_chainas the review route's prev/next ring.- It is a declared change to the zoom-ring rule. Today's ring is images only. A booth mixing images and audio now rings through both, in set order.
image_chainstays for its callers and tests.
SEEN_FILE = ".seen": a UTF-8 JSON array of rels. Not one rel per line,.blurred's shape: a file name may contain a newline, and a line format would split one such rel into two, neither of them real.- Written by
record_seen(booth, rel, items)from the review route, below the 404s and gated on the item record — the same gaterecord_viewhas. - Each write rewrites the whole file: the previous set plus
rel, minus rels no longer inbooth_items, sorted. It is deduplicated and pruned, so it never grows past the booth's item count. - Atomic replace, per the Booth's CLAUDE.md invariant 5 ("sidecar writes are atomic"), not this contract's INV-5.
- Seen is keyed by rel. A file replaced at the same path stays seen; a
deleted file drops out at the next write, and every count below
intersects with the current
review_chain. - The review route ALSO calls
record_view(existing U4 behaviour, unchanged). So reviewing a booth at full size refreshes "you looked" for the Desk exactly as opening its grid does..viewedand.seennever disagree about whether you looked at the booth;.seenonly adds WHICH items. - NEVER RAISES, like
record_view: failing to record a look costs the marker, not the page.
- Written by
read_seen(booth) -> set[str]is lenient and NEVER RAISES. It opens without following a symlink and without blocking, reads only a regular file of at most 1 MiB, and keeps only the array's string members. Anything else — a link, a FIFO, a directory, an oversized, malformed or too-deeply-nested file — reads as the empty set..seensits in an agent-writable directory, and a planted FIFO must not hang the review route.itemsis the route's ownbooth_itemsresult. It is passed in so that the prune ("minus rels no longer inbooth_items") costs no second walk.- Seen is UI state, not judgment. It is not exposed in
marks.jsonand it holds nothing.- It adds no lifetime RULE. Being a dotfile, its write does move
_newest_mtime. So does the.viewedwrite on the same request, so a review page ages a booth exactly as it does today.
- It adds no lifetime RULE. Being a dotfile, its write does move
C3 — in-place judgment (app.py, base.html)
wants_json(accept: str | None) -> bool takes the raw Accept header, so
it is a pure function a test can call directly. It is True only when the
header, split on commas, contains an entry whose media type, parameters stripped, is
exactly application/json and whose q-value is absent or greater than 0.
- Absent, empty,
*/*orapplication/*→ False. application/json;q=0→ False. A client that explicitly refuses JSON gets the redirect.- A near miss such as
application/jsonx→ False. - Every entry is parsed before anything is decided. One unparseable entry anywhere, before or after a good one, makes the whole header False.
- Any header that fails to parse → False. A q-value that is not a finite
number (
q=nan,q=inf) fails to parse. - It fails toward the 303.
The four mark routes (/answer, /note, /flag, /unmark) perform the same
write as today, then:
wants_json→ 204 No Content.- otherwise → today's
_mark_redirect(...), byte-identical: same status, sameLocation, same body.
back=view is a new landing for _mark_redirect, carried by the review
route's forms together with f=<rel>. It lands on
/b/<name>/view?f=<quote(rel)>#rail. This fixes the JS-off bounce too:
today's zoom flag form carries no back, so it lands on the gallery.
back=viewlands on the review only whenfnames an item inreview_chain, i.e. a media item. For anything else (a doc, a missing rel, an emptyf) the landing falls back to the booth page, exactly as a form with nobackdoes today.doc.htmlcarries no forms, so no shipped page sendsback=viewwith a doc.- The URL is built server-side from
name+quote(f), never echoed, so this is not an open redirect.
The client: one small script in base.html, bound to forms marked
data-inplace.
- POST the form with
Accept: application/json. - On 204, GET the current URL and replace every element carrying
data-region="<id>"with the same-id element from the response.-
The rule is "every region whose content can depend on marks is a region".
- On the lightbox: the verdict aside, each tile, the rail (its filter
counts change when you flag), and the header's open count and lifetime
line (
booth-status). - On a booth with marks but no set: the panel (
marks-panel). - On the standalone marks page: the header's open count (
booth-status) and the panel (marks-panel), one region around both its states so answering the last mark away swaps in the empty state. - On the review: the rail, the filmstrip and the tape.
- On the lightbox: the verdict aside, each tile, the rail (its filter
counts change when you flag), and the header's open count and lifetime
line (
-
The stage is never a region: replacing it would restart a playing video or audio track.
-
A TILE (
item-*) absent from the response is left alone and never deleted. Deleting it would shift every tile after it under the reader's eye. It is markedis-staleso it does not pass for current: un-flagging under?filter=flaggedis the case. The next navigation drops it. -
Any OTHER difference in structure — a non-tile region in the response that the page lacks, or one the page has that the response lacks — or a page with no region to swap at all, is not patched: the script reloads with a GET, so what you see is the server's truth.
-
The swap also carries the per-viewer state a reload would have reset but an in-place save must not:
- live media whose src is unchanged;
- a revealed blur;
- a closed doc;
- disclosures the reader opened or closed;
- every DIRTY control: a half-typed or edited note, a radio picked and not yet sent.
All of it is matched by IDENTITY, never by position: a form by its action and its hidden
ask/target/mark/ffields, a control by its form plus its name (plus its value for a radio or checkbox), a disclosure by the pick or form it holds. A flag that adds a tray row above a draft must not move the draft into the wrong box. The form just sent is the exception: its fields come back as the server rendered them, and its disclosure comes back folded.
-
- Saves are SERIALIZED. Each save runs its POST, its GET and its swap before the next begins, so an older snapshot never lands after a newer one (three quick flags show three flags). A form already queued or in flight ignores another submit: a double-click writes one note, not two.
- The script never re-POSTs. A retry after a lost response would re-apply
the judgment: a duplicate note, or a re-dated answer.
- On a non-204 HTTP response, or a network failure, it writes a fixed
message into the page's server-rendered status element
(
data-region="status", via textContent). After a beat (0.9 s, so the words can be read) it reloads the page with a GET, so what you see is the server's truth. - The one case where a non-JS submit happens is a script that cannot run at all. That is the plain form.
- On a non-204 HTTP response, or a network failure, it writes a fixed
message into the page's server-rendered status element
(
The server renders every state; the script only places it. This is U3's rule — a second renderer in JavaScript would be the same bug in a new language.
C4 — the Desk (index.html, app.index, list_booths)
list_booths gains five fields, all read in the one pass it already makes:
open_since: thecreatedof the OLDEST open pick in the booth, or None. Computed viaopen_marks, INV-2.Mark.createdis a STRING. It is parsed withdatetime.fromisoformat, never compared lexically: two ISO stamps with different offsets, or a legacy-import stamp, sort wrong as text.- An unparseable stamp sorts AFTER every parseable one, and name breaks the tie.
flags: the number of CURRENT items carrying a READABLE flag mark, shown on every Desk row that has any —flagged_targets(marks)intersected with the booth's item rels.flagged_targets(marks)is the ONE flag predicate. The Desk, the tray, the filmstrip, the tape and the review button all read it, and an unreadable flag entry counts nowhere. A flag whose file has since been deleted is an ORPHAN: it counts on no Desk row, and the tray lists it (C5) so it can be cleared.landed_at: the newest mtime among the booth's CONTENT — its regular files and symlinks with no dot-component in their path, each read bylstat. Deliberately not_newest_mtime(INV-5). Five refinements, each load-bearing:- Files only, never directories. Creating any dotfile (
.viewed, the marks file's temp-and-replace) bumps the booth directory's own mtime, so counting directories would make the flag you set after looking read as a delivery. - A symlink counts by its OWN mtime — when it was placed — never its target's. A link to a busy file outside the booth must not make the booth read as newly delivered.
- An empty booth landed at 0.0.
- One unreadable entry is skipped. Reading the whole booth as landed NOW for one bad entry would pin it in 'new' forever.
- A booth whose walk cannot run at all reads as NOW. It is shown as new rather than hidden as old.
- Files only, never directories. Creating any dotfile (
viewed_at: the mtime of.viewed, or None.preview: up to 4 image items as(url, blurred), first four in item order. A blurred one renders blurred, the same rule as the cover.- A booth with no images (an audio set, a report) shows today's kind
placeholder instead (
♪ audio,▦ page,▶ video,◆ files). - These are the original files displayed small with
loading=lazy. No thumbnail is GENERATED anywhere in R2; see Out of scope.
- A booth with no images (an audio set, a report) shows today's kind
placeholder instead (
The index renders three sections, always in this order:
- Needs you —
marks_open > 0, orhold == "unreadable".marks_opencountsopen_marks(...), which only ever returns PICKS. A booth whose marks are only flags or notes is the operator's own judgment, not a question to them, so it is NOT here.- A damaged
.marks.jsonholds its booth but is not open byopen_marks(errored picks are not open). Somebody has to fix it, so it must not hide in 'everything else'. It renders with the existing "marks unreadable" lifetime line. - Ordered by
(open_since, name), oldest question first. A booth heldunreadablehas noopen_since— even when a readable pick sits beside the damage, because the damage is the thing to fix — and sorts after every booth that has one.
- New since you looked —
not in_needs_you and (viewed_at is None or landed_at > viewed_at). Ordered by(-landed_at, name), newest first. - Everything else — in
list_booths' own existing order:(mtime, name)descending, wheremtimeis today's_newest_mtime. That is last activity first, with name as the tie-break (test_the_index_order_has_a_tie_breakerpins it). The Desk reuses that rule rather than stating a second one.- Flagging or viewing a booth moves it up this section. That is intended:
it is activity. It never moves the booth into (2), because (2) reads
landed_at(INV-5).
- Flagging or viewing a booth moves it up this section. That is intended:
it is activity. It never moves the booth into (2), because (2) reads
The side column holds:
- Benches:
read_benches(data_dir), non-retired, in the registry's existing order. Its error return renders as an error line, never as an empty list. This is the booth page's rule: damaged and absent must not render the same. - Agent-written URLs become links only when they are
http(s). A bench URL or a bookmark with any other scheme renders as plain text. Autoescape stops markup, not ajavascript:href. - Bookmarks come from the board the CLI writes: the booth named by
BOOTH_LINKS_BOARD, defaultlinks. They are read through the same never-raising path as_board_rows, which gets factored so both callers share it.- Shown: rows that are not booth URLs (
booth_target(url) is None). - Order: pinned first, then newest (
order_for_display). - Capped at 8, with a link to the full board.
- Shown: rows that are not booth URLs (
- Pickup: the existing upload form, unchanged, moved from the page head.
An empty section does not render — no heading, no box. This is the
load-bearing negative half of the kept-lane pair it replaces
('class="grid kept-grid"' not in html), carried forward into test_flow.py as
a pair: present when it has rows, absent when it has none. It applies to each
of the three sections and to the Benches and Bookmarks panels.
The kept/ephemeral lanes are removed: 23 of 24 live booths are kept, so the
lanes sort nothing. Kept status and the lifetime line (_lifetime.html,
unchanged) remain on every row.
C5 — the lightbox (booth.html, booth_view)
- Layout. Two panes on a gallery booth: the set on the left, the
verdict aside on the right (
position:sticky,data-region="verdict"). Under 1000px the aside stacks above the set, with its flags and notes collapsed as<details>, which needs no script.- The markup is a CLOSED
<details>. - Above 1000px, CSS alone shows its content (
::details-content) and hides its summary, so nothing is folded where there is room. - A browser without
::details-contentshows the fold at every width: one tap, never hidden.
- The markup is a CLOSED
- Board booths are unchanged. Anything with
links.mdkeeps today's single column. - The aside holds, top to bottom:
- open picks (the existing
_marks.htmlpick rendering); - the flag tray;
- notes;
- the booth-note form.
- open picks (the existing
- The flag tray is ordered by ORDINAL — a declared change from the marks
panel's
(created, id). It shows each flagged item's original file displayed small (no generated thumbnail), blurred if the item is blurred, with its #. The order is total with no tie-break, because rels are unique.- Orphan flags — flags whose target is no longer an item — follow the tray, by target, each with its unmark form. A flag the page cannot show must still be clearable, or it counts in the rail forever.
- The rail stays. Same element, same
.railclass (booth.html's cursor and base.html's--rail-hscript both read it), same filter hrefs, same group anchors. Whenrail.groupsis non-empty AND every group is one contiguous run in the rendered order, the grid additionally renders an inline group header before each group's first tile.- Groups come from basenames and the order from full paths, so groups can
interleave (
d1/aa,d1/bb,d2/aa). - A header would then either repeat or file an item under the wrong group,
so interleaved groups get no inline headers. The rail's jump links are
unaffected. It is a
<div>spanning the grid, never afigure.item, so the keyboard and the order check are blind to it by construction.
- Groups come from basenames and the order from full paths, so groups can
interleave (
- Every tile shows
#NN(its ordinal, zero-padded to the set's width). Each tile isdata-region="item-<url>", so the in-place script can replace exactly the tile it flagged. - An audio or video tile carries a
reviewlink to its review page. On those tiles a click drives the player, so without the link the review is reachable only by key.
C6 — the review (view.html, booth_view_file)
This applies to image, video and audio items. Docs keep doc.html.
A requested rel the filesystem cannot represent (a NUL byte, an over-long path) is a 404, as any other unknown rel is — never a 500.
- The stage: the artifact at fit size, with a 1:1 toggle for images ONLY.
- The toggle and its script are rendered and bound only when the stage is
an
<img>. - The toggle is a JS-only VIEWING convenience, as it is today: the button starts hidden and the script shows it. With scripts off the image shows at fit size, and no judgment depends on the toggle (INV-3).
- Video and audio get their native controls and no toggle. A toggle that
renders on audio and silently no-ops (today's script binds
getElementById('vimg')) is the failure this names.
- The toggle and its script are rendered and bound only when the stage is
an
- The rail (
data-region="rail") holds:- the item's ordinal
#NN(its number in the whole set, the same number its tile shows); K of M, where K is its position inreview_chainand M is the length ofreview_chain. The tape's "N of M seen" uses the SAME M, and N counts.seen∩review_chain;- its position within its group, when the review RING spans two or more groups (the gallery rail's own rule: one group for everything says nothing);
- the caption;
- the flag form (
back=view); - notes and the add-note form (
back=view); - any open pick TARGETING this item, answerable here (
back=view); - the booth's other open picks as a count and a link.
- the item's ordinal
- The filmstrip is
review_chainin order, with ordinals, flagged frames underlined and the current frame in the reticle. - The tape (B's device) is one segment per
review_chainitem: seen / flagged / current, plus "N of M seen". - The end of the set is not a separate page. On the last ring item the
rail adds a summary block: the seen count, the flag tray, and EVERY OTHER
open pick, answerable in place.
- That includes picks targeting other items, not only booth-level ones: the end of the set is where the remaining questions get cleared.
- Before the last item, the other picks are a count and a link.
- Keys (additive). Every key here, new and old, is ignored while focus
is in an
input,textarea,selectorcontenteditable, the sameisEditableguard view.html carries today, so F never fires mid-note:key action ← → and Space move. Shift+Space moves back. Space is left to a focused <video>/<audio>player, whose own play key it isF flag N focus the note Esc back to the grid, at #item-<url>so the grid scrolls to where you were
C7 — copy and brand (the two rulings that are not layout)
- Voice: plain and direct (ruling
voice=plain). Every NEW string R2 introduces says what it means, with no villainy and no jokes. Existing strings are unchanged unless their surface is rewritten. - No SVS emblem anywhere in the Booth's chrome (ruling
emblem=no). The brand dot and the reticle favicon from the SVOS retheme stay.
Invariants
-
INV-1 — one resolver.
ordinalis set inbooth_items. No route computes a position. -
INV-2 — order, stated. Each ordered surface has a one-line rule:
surface rule items sorted(rel)ordinals position in that review ring that, filtered to media filmstrip, tape the review ring flag tray by ordinal Desk sections fixed: needs → new → everything needs you (open_since, name)new since you looked (-landed_at, name)everything else list_boothsorder:(mtime, name)descendingbookmarks order_for_displayThe notes list keeps
(created, id). -
INV-3 — JS-off parity. Every judgment, filter and jump works with scripts disabled. The only JS-only affordances are:
- the keys;
- the in-place swap;
- the image 1:1 toggle (a viewing convenience, unchanged from today);
- the existing copy buttons and blur reveal.
The narrow-screen collapse is
<details>and needs no script. -
INV-4 — 303 byte-identity, for every request shape that existed before R2.
- For a request where
wants_jsonis False andbackis absent ormarks, each mark route's response (status, headers, body) is byte-identical to its pre-R2 response. back=viewis a NEW request shape with no pre-R2 counterpart. Its landing is specified in C3 and is the one declared exception.
- For a request where
-
INV-5 — two named clocks.
mtime/_newest_mtime: activity. It includes dotfiles and excludes locks, and it feeds lifetime and 'everything else'.landed_at: content only (non-dot entries), and it feeds 'new since you looked'.- Never the one where the other is meant: a mark or a view is not new content, and new content is not the only activity.
-
INV-6 — no second renderer. The in-place script inserts server-rendered HTML and builds none.
-
INV-7 — autoescape. No
|safeon any booth name, item name, caption, why or mark text. The flag tray and filmstrip render names through the same escaping path as the grid. -
INV-8 — blur honesty. A blurred item stays blurred on every new surface: the Desk preview strip, the flag tray, the filmstrip and the review stage. Reveal stays per-BROWSER and client-side (nothing persisted). Copy keeps admitting it is cosmetic.
Assertions that change (declared before the code, per CLAUDE.md)
| test | today | after R2 | why |
|---|---|---|---|
| test_booth.py L785 | class="grid kept-grid" present when a booth is kept |
absent; the kept booth appears in its Desk section with the kept lifetime line |
requirement 8: the lanes sort nothing |
| test_booth.py L786 | class="card card-kept" present |
replaced by the row carrying data-kept="1" |
same |
test_embed_browser.py test_the_keyboard_flag_actually_submits |
pressing f causes a NAVIGATION (page.expect_navigation()), and the reloaded page shows the flag |
pressing f causes NO navigation; the flag comes back from the server into the swapped tile. A window marker set before the keypress must survive, proving no reload |
with JS on, the flag now applies in place (requirement 3). The gallery reload was the no-JS design working, not a defect, and the plain-form path is still pinned by the INV-4 golden. The defect R2 fixes is the full-size EJECTION, view.html's flag form carrying no back. The test's real claim — the key reaches the server and the server's state comes back — is kept, and asserted more strictly |
| test_booth.py L789 | the kept booth renders BEFORE the ephemeral one (html.index("links") < html.index("scratch")) |
replaced by the Desk's stated order (needs → new → everything, each with its own key) | the kept-first order was the lane's; with no lane there is no kept-first rule, and a second hidden ordering would break INV-2 |
| test_booth.py L810-811 | lane absent when nothing is kept | these two SURVIVE unchanged (they assert absence and stay true) | — |
Every other existing assertion is expected to survive, and one of the TDD slices is "the whole suite green before any new test". Named because they were checked:
- the
vnav vprev/vnav vnextanchors (test_booth L569-591 and test_navigation L337) keep their classes and hrefs; Wipe nowstays in the booth header;class="boothhead"stays.
Accepted risks (named, not fixed)
.seenis read-modify-write without a lock. Two reviews of the same booth racing can drop one rel from.seen. The cost is cosmetic — a frame shown unseen on the tape — and the next look repairs it; a lock would buy a cosmetic count at the price of a lock file the lifetime clock must ignore.- A
.viewedsymlink planted by an agent freezes 'new'.viewed_atreads it bylstat, andrecord_viewrefuses to write through it (O_NOFOLLOW), so the marker never moves again: once content lands after it, the booth reads as 'new' however often it is opened. It fails in the visible direction — shown, never hidden — and needs write access to the booth, which already buys worse. The remedy is deleting the link. Itemgainsordinalwith no default.booth_itemsis the single construction site, keyword-only; a default would let a second site forget it silently (INV-1).
Out of scope
- Compare (r3).
- Thumbnails.
- 1–9 answer keys.
- Lifetime policy for answered picks.
- The verbatim path. A verbatim booth's media items remain reachable at
view?f=by URL, as today, and nothing in the verbatim page links there. - The link-board page (
/b/links/) beyond CSS.