Files
booth/booth/templates/_marks.html
T
Vuong Hoang 026a1fc392 fix(marks): v0.2.2 — nine findings from the cross-frontier bug-hunt panel
`/heid-bug-hunt` on U2's diff, four arms, artifact-only. Eight findings were
real against live code; a ninth was already closed by v0.2.1 and is recorded as
declined. Full triage in persistent-memory.d/2026-09-22-bug-hunt-panel.md.

THE LOCK LIFECYCLE (4/4 convergent, and two defects in one place)

`_Locked.__exit__` unlinked `.marks.lock` on the no-op path so a booth that had
never been marked was left exactly as it was found. `flock` binds to an INODE:
unlinking it under a blocked waiter leaves that waiter holding an exclusive
lock on a deleted file while the next writer creates a fresh lock and takes it
immediately. Two processes then run the read-modify-write concurrently, the
later os.replace drops the earlier one's mark, and both obeyed the protocol.

The cleanup existed to protect the booth's TTL, and was failing at that too:
creating or removing a directory entry bumps the DIRECTORY's mtime, which is
what `_newest_mtime` seeds from. The guard's comment reasons about the lock
file's own mtime and misses that the directory moved underneath it.

One fix: never unlink the lock, exempt `.<name>.lock` dotfiles from
`_newest_mtime`, and restore the directory's mtime after creating one.

THE READ PATH'S BLAST RADIUS

`_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on
`(created, id)`, so a stored `text` that was a dict or a `created` that was a
number raised out of the read path. `list_booths` reads every booth's marks on
every index load, so one hand-edited file returned 500 for `/` and `/healthz`
across all 25 booths. Guarded in two layers — a named type check and a
`_hydrate_safe` backstop that cannot raise — and an unreadable mark now renders
as ⚠ broken rather than as an empty note.

ALSO

- import_legacy_asks stamped `created` at whole-second resolution, so two
  sidecars from the same second lost the ordering the importer had just
  established and re-sorted alphabetically. Microseconds, per the stated
  `(mtime, name)` rule.
- The five mark-write routes ran a blocking flock on the event loop; they now
  dispatch through run_in_threadpool, asserted structurally like INV-1.
- `/answer` 500'd on a non-string `notes` form value where `/note` handled it.
- The inline-doc tile had a flag control and no note field.
- The marks panel was suppressed on any booth carrying a links.md.
- The viewer's arrow keys and Escape threw away a note being typed.

CLI

`booth marks` printed a traceback and exited 0 on a failed read, and `--wait`
emitted a whole JSON document per poll. `booth answer --wait` read a damaged
file as "not yet" and spun the full hour. Both now use real exit codes —
0 ok, 1 unanswered/timed-out, 2 no such pick, 3 unreadable — and `--wait`
prints once. `marks.read_error()` lets the CLI ask what the page must not: the
browser stays lenient, the machine consumer gets the truth.

`scripts/booth` had no tests; it has five now, run against the real script
under the system python3, which also makes them a live check on INV-1.

275 tests (253 before). Live service restarted, 25/25 booth pages verified 200.
2026-09-22 00:20:58 -07:00

160 lines
8.8 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# Shared MARKS panel — included by booth.html (auto-gallery view) and by
marks.html (the standalone page a VERBATIM index.html booth links to, since
a verbatim page is served as-is and can never render this inline).
One primitive, three shapes, one slot:
pick — a session declared N options; the operator chooses. Renders as a
radio form; answering POSTs to /answer and rewrites .marks.json.
note — free text the operator volunteered, in either direction.
flag — the operator pointing at one item. Rendered on the item's tile
rather than here, so the judgment sits beside the artifact; the
count below is the way back to them.
Works with JS off — plain form POST, every shape. An answered pick shows the
recorded judgment and a collapsed "change" form, because the mark is the
CURRENT judgment and not a log. #}
{# A mark carrying `error` is sorted out FIRST, whatever shape it claims. A
pick keeps its own ⚠ broken rendering below (richer — it has a declaration to
show); a broken note would otherwise render as an empty <pre> with a withdraw
button, indistinguishable from a note the operator wrote and then cleared,
and a broken flag would link to a target that is not there. Unreadable state
is visible state — the rule `_hydrate` states for picks, applied to all
three. #}
{% set broken = marks | selectattr('error') | rejectattr('shape', 'equalto', 'pick') | list %}
{% set picks = marks | selectattr('shape', 'equalto', 'pick') | list %}
{% set notes = marks | selectattr('shape', 'equalto', 'note') | rejectattr('error') | list %}
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
<section class="marks">
{% for a in broken %}
<article class="mark mark-note is-broken" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state">⚠ broken</span>
<span class="mark-id"><code>{{ a.id }}</code></span>
<span class="board-spacer"></span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this mark">×</button>
</form>
</header>
<p class="mark-error">This mark could not be read: {{ a.error }}</p>
</article>
{% endfor %}
{% for a in picks %}
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
<span class="mark-id"><code>{{ a.id }}</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>{% endif %}
<span class="board-spacer"></span>
{% if a.answer and not a.answer.complete %}<span class="mark-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
{% if a.answer %}<span class="mark-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
</header>
{% if a.error %}
<p class="mark-error">This question could not be read: {{ a.error }}</p>
{% else %}
{% if a.title and not a.multi %}<p class="mark-title">{{ a.title }}</p>{% endif %}
<p class="mark-prompt">{{ a.prompt }}</p>
{% if a.answer %}
<div class="mark-answer">
{% if a.multi %}
{% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
<div class="mark-answer-q">
<span class="mark-answer-qprompt">{{ q.prompt }}</span>
<div class="mark-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
{% if qa and qa.notes %}<pre class="mark-answer-notes">{{ qa.notes }}</pre>{% endif %}
</div>
{% endfor %}
{% else %}
<div class="mark-answer-choice">{{ a.answer.label }}</div>
{% endif %}
{% if a.answer.notes %}<pre class="mark-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
</div>
{% endif %}
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer">
{# The field is still `ask`: inline fragments in reports the operator
has already published POST that name, and breaking every landed
verbatim report to tidy a form field is not a trade worth making. #}
<input type="hidden" name="ask" value="{{ a.id }}">
{# On the standalone page, come back HERE — the booth's own page is a
verbatim report that cannot show the recorded judgment. #}
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
{% for q in a.questions %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
<fieldset class="mark-q">
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
<div class="mark-options">
{% for o in q.options %}
<label class="mark-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
<input type="radio" name="{{ field }}" value="{{ o.id }}"
{% if qa and qa.choice == o.id %}checked{% endif %}>
<span class="mark-opt-main">
<span class="mark-opt-label">{{ o.label }}</span>
{% if o.detail %}<span class="mark-opt-detail">{{ o.detail }}</span>{% endif %}
</span>
</label>
{% endfor %}
</div>
{% if q.notes %}
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
{% endif %}
</fieldset>
{% endfor %}
{% if a.notes_enabled %}
<textarea class="mark-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
<div class="mark-actions">
<button type="submit" class="mark-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
</div>
</form>
</details>
{% endif %}
</article>
{% endfor %}
{% for a in notes %}
<article class="mark mark-note" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state mark-state-note">note</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
{% else %}<span class="mark-target">on this booth</span>{% endif %}
<span class="board-spacer"></span>
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note">×</button>
</form>
</header>
<pre class="mark-text">{{ a.text }}</pre>
</article>
{% endfor %}
{% if flags %}
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
<span class="mark-id">{{ flags|length }} item{{ '' if flags|length == 1 else 's' }}</span>
</header>
<ul class="mark-flaglist">
{% for a in flags %}
<li><a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></li>
{% endfor %}
</ul>
</article>
{% endif %}
{# The operator volunteering a remark, which before marks had no mechanism at
all — this is the direction that was running through chat. #}
<form class="mark-add" method="post" action="/b/{{ name_url }}/note">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
<button type="submit">Add note</button>
</form>
</section>