`.forever` was the only way to say three different things — "this is durable",
"I have not answered yet", "I am still looking" — and the census said it was
carrying all three: 17 of 24 live booths (70%, up from 54% the day before).
Three of the four booths in the fleet awaiting an answer had been pinned by
hand as well, and 10 of the 17 were younger than the TTL, so the sentinel had
bought them nothing and was pressed pre-emptively.
Only the first meaning is what `keep` means. The other two are facts the
service already held and did not consult.
KEPT `.forever` present never swept (unchanged)
HELD an open pick, or marks we cannot read never swept (new)
EPHEMERAL everything else 24h (unchanged)
Viewing is activity: a deliberately-served response from a booth's own page
route writes `.viewed`, which is a dotfile and not a `.lock` dotfile, so
`_newest_mtime` already counts it. There is no new arithmetic — `booth_age_seconds`,
`is_expired` and `expires_in` are unchanged. Machine reads are excluded on
purpose: an agent must not be able to hold its own booth open by polling for
the answer it is waiting on.
The hold is unbounded, and what makes that safe is visibility plus two exits
that already existed. Every surface whose chrome the Booth owns says
`held until answered` where the countdown was, and `booth rm` / the UI x /
`DELETE /b/<n>` take a held booth exactly as they take a kept one. A hold is
protection from the timer, never from the operator.
Three cross-frontier panels ran and each found a class the others could not:
* the paraphrase panel found that two reads of one file are not one read of
one state — the contract's `is_held(marks_for(c), read_error(c))` could
resolve to `([], None)`, the pair that deletes. `hold_read` is one read.
* the code-review panel found, 4-of-4, that the booth header's board branch
rendered no lifetime at all; and that five of seven invariant tests passed
under the change that defeats them.
* the bug-hunt panel found four more paths where a failed read still
authorized a delete, and a `record_view` that followed a planted symlink.
`is_held` became `hold_reason`, which returns the reason rather than a bool
beside a string that can disagree with it.
Prediction, to re-count on or after 2026-10-06: the `.forever` rate falls to
the booths that are genuinely durable references. Only 4 booths carry marks at
all, so this rests on both halves of the unit; a null result cannot distinguish
a wrong diagnosis from a habit that outlived its need.
406 tests (341 before). Contract: docs/contracts/u4_derived_lifetime.contract.md
42 KiB
contract_version, module, purpose, depends_on, language, complexity, estimated_loc, used_by, touches, assumptions, open_questions
| contract_version | module | purpose | depends_on | language | complexity | estimated_loc | used_by | touches | assumptions | open_questions | |||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1.0 | booth.app (lifetime) | A booth's lifetime stops being a boolean somebody remembered to press and becomes a fact derived from the booth's own state. Today there is ONE lifetime (24h from the newest mtime in the tree) and ONE escape hatch (`.forever`), and the measurement says the escape hatch is carrying the main load: 17 of 24 live booths (70%) hold the sentinel, up from the 13 of 24 (54%) counted on 2026-09-21. That is not `ephemeral with an exception`; it is two lifetimes wearing one lifetime's clothes, with the operator doing the sorting by hand. This unit adds the two facts the sweeper was missing -- a booth the operator still owes an answer to is HELD, and looking at a booth is ACTIVITY -- so the cases that were pressing `.forever` for `not yet` stop needing it, and `keep` is left meaning only what it says: this is durable. |
|
python | medium | 130 |
|
|
|
|
U4 — derived lifetime
The defect, stated precisely
One lifetime (24h from last touch) and one shape (a folder), serving five jobs with different lifetimes. —
docs/design/information-architecture.md
.forever is the escape hatch for that mismatch, and the measurement says it is
no longer an exception:
| date | booths carrying .forever |
rate |
|---|---|---|
| 2026-09-21 (IA doc) | 13 of 24 | 54% |
| 2026-09-21 (re-count) | 14 of 25 | 56% |
| 2026-09-22 | 17 of 24 | 70% |
Both the rate and the absolute count rose, so this is not the denominator shrinking as the sweeper ran. A boolean that 70% of the population sets is not an exception, it is the default with extra steps.
The reason it gets pressed is that it is the only way to say any of these:
| what the operator means | what he has to press |
|---|---|
| "this is a durable reference" | .forever |
| "I have not answered the question yet" | .forever |
| "I am still looking at this" | .forever |
Only the first is what keep means. The other two are facts the service already
holds and does not consult: there is an open pick in .marks.json, and
somebody just loaded the page. U4 consults them.
The diagnosis has a live positive control
Counted 2026-09-22 against ~/booth-data. A census of the whole population, not
a sample, and every value is a deterministic file fact (existence, mtime) — so
one observation per booth is the measurement, not an anecdote. The population
churns (26 -> 24 over the previous session); re-count rather than trusting these.
| live booths | 24 |
carrying .forever |
17 (70%) |
carrying .marks.json at all |
4 |
| of those, with an open pick | 4 of 4 |
open pick AND .forever |
3 |
Three of the four booths in the fleet that are waiting on an answer have ALSO been pinned by hand. That is the "not yet" case, caught in the act: the operator pressed the durable-reference sentinel because there was no other way to say "do not take this, I have not answered it". U4 makes those three stop needing it.
The staleness distribution says the same thing from the other side. Of the 17
kept booths, 10 are under ONE day old — younger than the TTL, so the
sentinel has bought them nothing yet and was pressed pre-emptively. (An earlier
draft of this paragraph said "12 under 1.5 days" and called that younger than
the TTL; 1.5 days is not younger than 24 hours, and the claim only holds at the
one-day line. Caught by the cross-frontier paraphrase panel, 2026-09-22 — the
measurement was right and the sentence was not.) Only 4 are old enough
(2.4-4.6 days) that keep is the only reason they still exist. A
sentinel pressed on a booth that was in no danger is not a durability decision;
it is "not yet", written in the only vocabulary available.
⚠ The hold's live blast radius is SMALL today — 4 booths have marks at all. The
17-to-something prediction therefore rests on both halves of this unit, and on
the sentinel becoming unnecessary rather than becoming forbidden. If the rate
does not move, the honest readings are: the diagnosis was wrong, OR the habit
outlived the need, and the fortnight re-count cannot tell those apart on its
own. The three open-pick-plus-.forever booths are the ones to watch, because
for them the mechanism is now unambiguous.
The record
A booth is in exactly one lifetime state, decided in this order:
KEPT .forever present never swept (unchanged)
HELD an open pick, or a never swept while (new)
.marks.json we cannot read that holds
EPHEMERAL otherwise swept when
age > ttl (unchanged)
age is unchanged: now - _newest_mtime(booth), the newest mtime in the tree
excluding .<name>.lock. Viewing is folded in through that existing rule,
not beside it — a view writes .viewed, which is a dotfile and not a lock
dotfile, so the age function already counts it. There is no new arithmetic.
What counts as a view
One line, because CLAUDE.md invariant 6's test applies to rules as well as orders: a deliberately-requested response FROM a booth's own page route is a view; a machine read, an asset fetch, and a request that does not resolve are not.
Three words in that rule are load-bearing and the first draft said "HTML page",
which was wrong twice. ?download=1 is a zip served by the booth-page route and
IS a view — the operator asking for the whole booth is as deliberate as looking
at it. And a /view?f=<missing> that 404s is NOT one: the route matters, but so
does whether anything was served, or a crawler walking dead zoom URLs holds a
booth open forever. record_view therefore sits below the zoom route's file
validation and above the booth route's verbatim/zip fork.
| route | view? | why |
|---|---|---|
GET /b/<n>/ |
yes | the booth page — gallery, verbatim report, or ?download=1 zip |
GET /b/<n>/view?f=… |
yes | the zoom / doc page; a bookmarked zoom URL is somebody looking |
GET /b/<n>/marks |
yes | the standalone judgment page — for a verbatim booth this IS the booth page |
GET /b/<n>/marks.json |
no | a session polling. An agent must not be able to hold its own booth open |
GET /b/<n>/<file> |
no | issued BY the page. A hotlinked image would otherwise keep a booth alive |
GET / |
no | the IA's rule: "deliberate act, so it cannot be triggered by browsing the index" |
GET /healthz |
no | a monitor is not a viewer |
⚠ Named rather than hidden: scripts/layout-probe.py sweeps every booth page,
so running it resets every booth's clock. That is the correct reading of the
rule (it is a GET of every booth page), it is recoverable (one extra TTL), and
it is a dev tool. A note goes in the probe.
⚠ A browser that speculatively prefetches a hovered link records a view the
operator did not quite take. Accepted: the failure mode is a booth living one
extra day because he nearly opened it, and the alternative is sniffing
Sec-Fetch-* headers, which is a fragile rule pretending to be a crisp one.
Checked, because it would have been silent: nothing in the fleet polls a
booth page. Homepage's siteMonitor for the Booth is
http://10.100.10.50:8090/healthz, which is on the not-a-view list; there is no
cron entry and no systemd timer touching /b/…. Had Homepage been pointed at a
booth URL instead, every booth would have become immortal on deploy and nothing
would have reported it.
Release is activity, on purpose
Removing .forever bumps the booth directory's mtime, so a released board
survives another full TTL. Today that is an accident of directory metadata
that app.py documents as "not intuitive" and scripts/booth warns against.
U4 does not change the behaviour and does not retire the test that pins it. It
changes the behaviour's reason: booth_unkeep calls record_view, so a
released board gets one full TTL because releasing a board is somebody
touching it, which is a rule, and no longer because of which syscall happened
to write a directory entry, which is not.
The existing tests (test_releasing_a_board_RESETS_its_ttl_clock,
test_released_board_is_sweepable_once_it_ages_again) are untouched, and that
is a correction to this contract's first draft, which said they would each gain
an assertion that the marker is present. They must not: the first one unlinks
the sentinel by hand, not through the route, so it is a test of the mtime
mechanism and a route side-effect does not belong in it. The route behaviour
gets its own test in the new file, and the old test gains a comment pointing at
it.
The marker's mtime must be NOW, which Path.touch() gives and which the
contract's first draft left unsaid. An implementation that wrote the file with
any older timestamp would satisfy "the marker is there" while the extra TTL
still came from the directory-mtime accident this section exists to replace —
the new reason would be decoration over the old mechanism. Flagged by the
paraphrase panel, 2026-09-22.
Signatures
# booth/app.py
VIEW_MARKER = ".viewed"
"""Records the last deliberate look at a booth. A dotfile, so `booth_items`
skips it and it costs nothing in counts, galleries or zips — and NOT a `.lock`
dotfile, so `_newest_mtime` counts it and the existing age rule picks up the
view with no new arithmetic."""
def record_view(booth: Path) -> None:
"""Note that somebody deliberately looked at this booth.
Touches VIEW_MARKER; `_newest_mtime` does the rest. NEVER raises: a
read-only mount, a booth we do not own or a full disk cost the timestamp,
not the page. A booth whose view cannot be recorded simply ages on its
content mtime, which is today's behaviour for every booth.
"""
def is_held(marks: Sequence[Mark], error: str | None) -> bool:
"""True if this booth still owes the operator an answer and must not be swept.
PURE — it takes the result of a read and does none of its own, so the index
card and the sweeper cannot answer differently about the same booth. That
is U1's rule (one resolver, every surface reads the record) applied to
lifetime.
FAIL-SAFE on `error`. `marks_for` is lenient because a review page that
will not render is worse than one missing an annotation; the same leniency
on the DELETE path would wipe the booth whose judgment we had just failed
to read. Reads lenient, deletes strict.
Openness itself is `open_marks` and nothing else (U2 INV-2).
"""
return error is not None or bool(open_marks(marks))
is_expired is unchanged and stays a pure age question — the existing
separation ("expiry arithmetic and reaper policy are kept apart so they cannot
drift into each other") is the reason is_kept is not consulted there either.
sweep_once remains the only caller that honours a pin, and now honours two.
# booth/marks.py — stdlib only, like the rest of that module
def hold_read(booth: Path) -> tuple[list[Mark], str | None]:
"""ONE read of `.marks.json`, answering BOTH questions the lifetime rule
asks: what is still open, and whether the file could be read at all.
Two calls would read the file twice, and two reads of one file are not one
read of one state — the pair that loses the race is `([], None)`, which is
the pair that deletes.
On a clean file the marks are what `marks_for` would return, because
`_read_raw_strict` raises rather than dropping an entry. So one read serves
the badge too, and the lenient reader comes back only on the error path.
"""
def sweep_once(data_dir, ttl_seconds, now=None) -> list[str]:
...
if is_kept(child):
continue
if is_held(*hold_read(child)): # NEW — ONE read
continue
if is_expired(child, ttl_seconds, now):
shutil.rmtree(child)
def list_booths(data_dir, ttl_seconds, now=None) -> list[dict]:
...
marks, marks_error = hold_read(child) # NEW — one read, both facts
if marks_error is not None:
marks = marks_for(child) # lenient, for the panel
booths.append({
...
"marks_error": marks_error, # NEW — the card says so
"held": is_held(marks, marks_error), # NEW — the same predicate
})
What renders
The lifetime line, on the ephemeral index card and in the booth header. Three states, one of which is new:
| state | line | why |
|---|---|---|
| ephemeral | 12 items · expires in 3h 20m |
unchanged |
| held, open pick | 12 items · held until answered |
says what holds it AND what releases it |
| held, unreadable | 12 items · held · marks unreadable |
the one hold nothing will release on its own |
| kept | 12 items · kept |
unchanged, kept lane |
The hold REPLACES the countdown at every age, not only once the booth is
old. A held booth that is four hours old shows held until answered, not
expires in 20h. expires_in is still computed and still correct (INV-1);
it is simply not what the surface says, because a number counting down to a
deletion that will not happen is the silent-stopped-clock failure in its other
costume — the screen announcing an expiry the sweeper will never carry out.
Flagged as readable-two-ways by the paraphrase panel, 2026-09-22; settled here.
A booth that is not counting down always says why. That is the whole safety
argument for an unbounded hold: .forever was at least visible as a lane; an
invisible rule that silently stops the clock would be strictly worse than the
boolean it replaces.
Three surfaces, not two, and the third was found by looking at the live
service rather than by the suite. A verbatim booth's own index.html is served
untouched by design, so it has no Booth-rendered header for the line to live in
— and a report that ASKS the operator something is the archetype of a held
booth. GET /b/<n>/marks is the only other page whose chrome the Booth owns, so
the line goes there too. Without it, the booths most likely to be held would be
exactly the ones that never said they were. (U3 is the unit that gives a
verbatim booth real chrome; until then, this is the honest coverage.)
Kept beats held in the display, because a kept booth is in the kept lane and is
exempt either way — showing two reasons for one exemption is the
two-representations-of-one-state trap flag_id's docstring names.
An unreadable marks file is the exception, and it rides along even on a kept
board: kept · marks unreadable. Damaged judgment is not a second exemption,
it is a thing somebody has to go and fix, and the kept lane holds the durable
boards — the ones where losing the operator's marks costs most. A kept card that
said only kept would hide the single case that needs a human. The card's
held and marks_error are therefore RAW FACTS, true regardless of keep, and
only the display has a precedence. The paraphrase panel found the two readings
of "exactly one lifetime state" that this settles.
Scope — the blast-radius pass
graphify explain on sweep_once, is_kept, list_booths, _newest_mtime,
booth_age_seconds, open_marks, KEEP_MARKER, cross-checked with grep.
Graphify reported the call structure and, as expected, missed both route
callers of list_booths (index() and healthz(), now at app.py:761 and :774) —
they are function-local inside create_app, which is the known AST blind spot.
Grep caught them. Neither tool alone was sufficient; this is the third unit in
a row where that has been true.
Production, 7 files: booth/app.py, booth/marks.py (hold_read, added),
booth/templates/_lifetime.html (new), booth/templates/index.html,
booth/templates/booth.html, booth/templates/marks.html,
booth/templates/base.html.
Docs/CLI, 4 files: scripts/booth, scripts/layout-probe.py, README.md,
CLAUDE.md.
Tests, 2 files: tests/test_lifetime.py (new), tests/test_booth.py.
⚠ This census said "Production, 4 files" in the first draft and omitted
_lifetime.html, marks.html, marks.py and layout-probe.py — three of
which the body text elsewhere required, which is the contradiction both
Gróa and Hulda flagged independently. An inventory that disagrees with the
prose next to it is worse than no inventory: it reads as a closed set.
Not touched, and checked rather than assumed: booth/items.py,
booth/manifest.py, booth/links.py, booth/asks.py, booth/inline.py.
The three cross-frontier panels, and what they changed
All three ran on 2026-09-22 and all three earned their place — and each found a class the other two could not. Triaged per the cross-frontier discipline rather than adopted.
Paraphrase panel (01M34VX0SH23Y3VC92E7GM4S70, four arms). Seven flags.
Five folded into the prose above: the hold replacing the countdown at every
age, deletes strict scoping to the reaper alone, the zip and the 404 in the
view rule, the marker's mtime, and the CLI warning staying true. Two changed
more than wording:
- Hulda — the two reads are not one state. The only finding on this round
that changed CODE. See the
hold_readassumption in the frontmatter. - Gróa and Hulda, independently — the blast-radius census contradicted the prose beside it. It named four production files while the body required three more. An inventory that disagrees with its own document is worse than none, because it reads as a closed set.
Hulda also caught a number: this contract claimed 12 kept booths were "under 1.5 days old — younger than the TTL". One and a half days is not younger than twenty-four hours. The measurement was right, the sentence was not, and it is the one place the diagnosis overstated itself.
Code-vs-contract panel (01M34WAFJC3RTERFYBBZJN1SVG, four arms). All
four arms found the same drift — the strongest signal either panel produced
on this unit. The booth header's sub-line forks on {% if board %}, and the
lifetime macro sat only in the {% else %}, so a booth carrying links.md
rendered a link count and nothing at all about its lifetime. INV-4 says the
templates have no path that renders neither; that was a path, reachable by the
release button or by a hand-made board.
Regin and Kimi recommended amending INV-4 to carve the board header out, on the grounds that board-header layout belongs to U7. Declined; the code is fixed instead. Cutting an invariant down to fit an implementation gap is the wrong direction when the fix is one template edit, and U7 owns navigation and section layout — not whether a header states a lifetime. Gróa's "fix it" was right.
The same panel showed that most of the INV falsifier tests did not
discriminate, which is the more useful half of the round. The header test
never rendered a board. The kept-beats-held test only rendered the index, where
kept cards took a hardcoded string and never reached the macro at all. The
INV-5 test called record_view directly instead of GETting the routes the
invariant is about. The INV-7 tests asserted the marker's absence rather than
the age, so a handler writing any other non-dot file would have passed. INV-6's
had no doomed sibling, so "spare everything" would have passed. Each is now
written to fail under the change that defeats it, and the board-header pair was
verified RED against the pre-fix template rather than assumed.
Bug-hunt panel (01M34Y2R0RAJRSN36Q8K4KAB36, four arms). The round that
changed the most code, and the one that found a class the other two could not
see by construction: a read that FAILED still resolving to "no hold", and
therefore to a delete. That is the invariant this unit declared to the panel,
and the panel found four independent paths through it. No single arm found
all four.
- An entry-level hydration error lost its hold.
.marks.jsonparses, one mark fails normalization,_hydrate_safereturns aMarkcarryingerror, and_is_openreturns False for an errored pick — on purpose, because a broken pick can never be answered. So the booth read as not-held and swept, while the panel beside it rendered the broken mark in full. The fail-safe was built for FILE-level damage and missed ENTRY-level. This is the strongest finding of all three rounds. - A present-but-blank
.marks.jsonswept._read_raw_strictearly-returns for whitespace-only content — right for the write path it was written for, wrong for the delete path. Our writer never produces a blank marks document, so a blank one that exists is something that went wrong. _newest_mtimereturned 0.0 when the booth's own stat failed, making it maximally ancient and therefore the FIRST thing the sweeper takes. Pre-dates U4; U4 is what turned the age read into a life-or-death read.is_keptcollapsed a stat failure into not-kept.Path.exists()maps ELOOP and EACCES to False, so a kept booth whose sentinel could not be stat'd became sweepable.
is_held is gone; hold_reason replaced it. A boolean plus a separate
error string is two representations of one state, and Regin independently
flagged that the display could not distinguish the two holds. One function now
returns the REASON — "open", "unreadable", or None — and every surface reads
it off the same value the sweeper acts on. That closes findings 1 and Regin's
together, which is why it is a rewrite rather than an extra clause.
Convergent, 3-of-4: record_view followed a planted symlink. Path.touch()
follows an existing link, so a booth carrying .viewed -> /anywhere turned every
page view into an mtime write at an arbitrary path under the service uid — and
any fleet session can write into a booth, because making a folder is the whole
API. Now an O_NOFOLLOW create plus os.utime(fd), so a planted link raises
ELOOP into the existing swallow and view-recording quietly stops for that booth.
The utime is also what makes the marker read as NOW, which this contract
already required and O_CREAT alone does not do.
Two more the panel found in code this unit touched:
?f=.marks.lockheld a booth open. The zoom route recorded a view for any path that stats inside the booth, including a lock file the service created itself.record_viewnow sits belowfind_itemand fires only for a real item — which also makes the comment beside it true, where before it claimed more than the code did.- Releasing an ALREADY-released booth refreshed its TTL forever. The
unconditional
record_viewonunkeepcontradicted that route's own no-op promise and diverged from the CLI, which removes the sentinel without recording anything. Now gated on something actually having been released. The same edit fixes a pre-existing 500: a.foreverthat is a DIRECTORY raisedIsADirectoryErrorstraight through the route, which made the card's release button permanently dead for that booth.
Also fixed: a docstring this unit's own fix made stale. sweep_once still
claimed "one lenient read plus one strict read" after hold_read reduced it to
one. Kimi's framing is the right reason to care — a maintainer "optimizes" back
to two calls on the comment's authority, and rebuilds the seam the function
exists to kill.
Re-declared as parked, not adopted: Regin distinguished a stale-DECISION
window (hold checked, then rmtree) from the torn-FILE race already parked at
park/booth-sweeper-rename-then-delete-to-close-the. The distinction is real
and the fix is the same rename-then-delete, so it parks with its sibling.
Five pre-existing defects the panel surfaced in touched files — a booth name
reaching a JS string context, an unguarded links.md read, an index sort with
no tie-breaker, marks.json reporting damage as empty success — are fixed in
their own commit rather than smuggled into this unit's. See that commit.
⚠ The capture tooling failed silently and the panel caught it, not us. The
files/ tree shipped to the arms was EMPTY: the snapshot loop iterated for f in $IN over a multi-line variable, and zsh does not word-split unquoted
parameter expansions the way bash does, so it ran once against a path that was
the whole list. jekyll recovered by re-applying the bundled diff to HEAD and
verified every file byte-identical, so the round is sound — but the failure mode
is the dangerous one: an empty bundle reads exactly like a clean result.
Seam review — against the real module surface
Checked against booth/marks.py itself, not against U2's contract prose.
| borrowed | real surface | verdict |
|---|---|---|
open_marks(marks) |
marks.py:542, takes Sequence[Mark], returns list[Mark] |
matches |
marks_for(booth) |
marks.py:514, _read_raw + _hydrate_safe + sort; total |
matches |
read_error(booth) |
marks.py:262, returns str | None, catches its own MarksCorrupt |
matches — and it is total, which is_held's fail-safe branch depends on |
_is_open semantics |
marks.py:525: pick only, error is None, partial counts open |
matches the assumption above |
_newest_mtime lock rule |
app.py:220: skips p.name.startswith(".") and p.name.endswith(".lock") |
.viewed is counted — confirmed at the source, not inferred |
Mark import in app.py |
app.py:145-160 imports open_marks, marks_for, marks_for_target, as_dict — not Mark |
is_held's annotation needs Mark added to that import list |
read_error import in app.py |
not imported either — U2 left it to the CLI, which is its only caller today | must be added to the same block; U4 is its first in-service consumer |
zip_booth dotfile skip |
app.py:445ff: p.is_file() and not p.name.startswith(".") |
.viewed never reaches a zip — confirmed, not inferred from booth_items |
booth_items dotfile skip |
items.py:182: not p.is_file() or p.name.startswith(".") |
.viewed is not an item |
GET /b/<n>/asks |
app.py:1105, a 308 redirect to /marks, not its own render |
records a view through the /marks handler. No separate call, and adding one would double-count |
| route concurrency | booth_view, booth_view_file, booth_marks_page are all def, not async def |
FastAPI runs them in a threadpool, so record_view's write cannot block the event loop |
Three rows of that table are the kind of thing only this pass finds: the cold panel reads one contract, and a signature that is fine in isolation says nothing about whether the name it needs is in scope at the call site.
SR-1 — why read_error is safe to call per booth per index load, which the
signatures alone do not say. _read_raw_strict checks S_ISREG before it
calls read_text (marks.py:236). That ordering is the v0.2.2 fix: st_size is
0 for a FIFO and 0 for a symlink to /dev/zero, so a size cap alone lets both
through and read_text then either blocks with no EOF or allocates until the
kernel intervenes — across every booth, on GET /, which is a service-wide
hang rather than one bad card. U4's decision to spend a second read on the hot
path depends on that guard already being there. It is; checked at the source.
Out of scope
-
A bound on the hold. An abandoned pick holds its booth forever. Detecting "abandoned" needs state the Booth does not have (is any session still polling?), and the honest alternative — an arbitrary N-day cap — trades a visible immortal booth for a silent deletion of an open question. Visibility plus
booth rmis the answer for v1. -
A reason string on
.forever. "keep survives as an explicit, reasoned pin" is read here as a pin the operator reasoned about, not a pin carrying a recorded reason. Awhyon keep does not close the measured defect — the 70% is people using keep for things that are not keep, and this unit gives those things their own mechanism. Parked per the anti-creep gate. -
A third index lane for held booths. A booth waiting on the operator is the most actionable thing on the index, and it already carries the
? N openbadge. Lane structure and ordering are U7's, and adding a lane here would set an ordering rule that U7 then has to live with. -
Closing the
marks._Locked.__enter__mtime-restore race. See the assumption above: the clean fix costs everyrsync -apopulated booth. The comment there stays, and stays accurate. -
booth lsmarking held booths. Open question, parked. -
Closing the view-during-sweep race, which U4 WIDENS.
sweep_oncecallsshutil.rmtreewithout holding anything, so a write landing inside that call can make it raise partway and leave a stump directory. The race is pre-existing — every write route has always had it — but U4 widens it, becauserecord_viewfires on every booth-page GET and the case that collides is precisely "the first look at a booth that has been silent for 24 hours", which is the state the sweeper acts on.The fix is known and small:
os.renamethe booth to.sweeping-<name>first (atomic, and a dotfolder the scan already skips), thenrmtreethe renamed path, plus a cleanup of leftovers at the top of each tick for the crash-between-the-two case. It is NOT done here, per the anti-creep gate: both "in" and "park" are defensible, so it parks. The arithmetic is that the collision needs a GET inside a ~10 msrmtreeon a booth nobody has opened in a day, the sweeper ticks every 15 minutes, and the consequence is a stump that survives one more TTL — against which a sweeper rewrite is not a v1-path trade. Named here so it is a decision and not an oversight, and parked on the henge atpark/booth-sweeper-rename-then-delete-to-close-the(id 83) so it has a home rather than only a paragraph.
Invariants
INV-1 — Age arithmetic is unchanged. booth_age_seconds, is_expired and
the expires_in values on both surfaces are computed exactly as before. A view
enters through _newest_mtime as a file in the tree, not as a term in a new
formula. Falsifiable: a booth with a .viewed and a booth with any other
non-lock dotfile of the same mtime report the same age.
INV-2 — sweep_once is the only caller that honours a hold. is_expired
stays a pure age question; booth rm, POST /b/<n>/delete and
DELETE /b/<n> delete a held booth exactly as they delete a kept one.
Falsifiable: a held booth is still reported expired by is_expired and is
still deleted by the delete routes.
INV-3 — One predicate, ONE READ, one answer. The index card's held, the
booth header's, the marks page's and the sweeper's exemption all come from the
same pure is_held, and each call's two inputs come from a SINGLE read of
.marks.json via hold_read — never from two reads stitched together, which
is a pair that described the booth at no instant. Falsifiable: for any booth, what
list_booths reports as held and what sweep_once refuses to take agree —
tested directly rather than by inspection, because that is the falsifiable form.
(open_marks is still called directly for the N open COUNT. A count is not a
lifetime decision, and the first draft of this invariant forbade it by accident
— the rule is that no exemption and no held label is derived except through
is_held.)
INV-4 — A booth that is not counting down says why. Every non-kept booth renders either a countdown or a named hold on every surface whose chrome the Booth owns: the index card, the booth header, and the marks page (which is the only one of the three a verbatim booth has). Falsifiable: the templates have no path that renders neither, and the one line is a single macro rather than three conditionals that can drift.
INV-5 — Recording a view cannot fail a request. record_view swallows
OSError. Falsifiable: a booth whose directory is read-only still returns 200
for its page, its zoom page and its marks page.
INV-6 — An unreadable .marks.json holds its booth. The reaper never
deletes judgment it could not read. Falsifiable: a booth with a corrupt
.marks.json, aged past the TTL, survives sweep_once.
INV-7 — Machine reads do not hold a booth open. GET /b/<n>/marks.json and
GET /b/<n>/<file> do not write VIEW_MARKER. Falsifiable: polling either,
repeatedly, leaves the booth's age untouched.