feat(blur): a booth can be fogged as a whole, composing with per-item blur

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.
This commit is contained in:
vh
2026-09-23 17:06:37 -07:00
parent 65e7dc2a4e
commit c1108a1966
6 changed files with 190 additions and 5 deletions
+22
View File
@@ -212,3 +212,25 @@ old = '''
var d = shown(btn.getAttribute('data-desc') || '');'''
new = '''
var d = btn.getAttribute('data-desc') || '';'''
[[mutation]]
label = "booth blur OVERRIDES per-item instead of composing"
file = "booth/items.py"
test = "tests/test_booth.py::test_booth_blur_composes_with_per_item_and_never_overrides_it"
old = '''
blurred=rel in blurred or (booth_blur and kind in BLURRABLE_KINDS),'''
new = '''
blurred=(booth_blur and kind in BLURRABLE_KINDS),'''
[[mutation]]
label = "an unreadable booth-blur marker reveals instead of fogging"
file = "booth/items.py"
test = "tests/test_booth.py::test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals"
old = '''
except OSError:
return True # cannot tell -> fog it; see above'''
new = '''
except OSError:
return False # cannot tell -> reveal it'''
+76
View File
@@ -1650,3 +1650,79 @@ def test_the_board_delete_dialog_cannot_be_rewritten_by_a_link_row(tmp_path):
# both arguments must go through it, not just one
assert "shown(btn.getAttribute('data-desc')" in html
assert "shown(btn.getAttribute('data-url')" in html
def test_booth_blur_composes_with_per_item_and_never_overrides_it(tmp_path):
"""The operator ruled booth-level blur in; design-dev specced the semantics
and this is the half that is ours.
COMPOSES, never overrides: an item is blurred iff the booth is blurred OR it
is in `.blurred`. Turning booth blur off must leave 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.
Defeating change: assigning `Item.blurred` from the booth flag instead of
OR-ing it."""
from booth.app import set_blurred, set_booth_blurred
from booth.items import booth_items
b = tmp_path / "g"
b.mkdir()
for n in ("a.png", "b.png", "c.mp3"):
(b / n).write_bytes(b"x")
set_blurred(b, "b.png", True)
def state():
return {i.rel: i.blurred for i in booth_items(b)}
assert state() == {"a.png": False, "b.png": True, "c.mp3": False}
set_booth_blurred(b, True)
# audio has nothing to hide from a glance
assert state() == {"a.png": True, "b.png": True, "c.mp3": False}
set_booth_blurred(b, False)
assert state() == {"a.png": False, "b.png": True, "c.mp3": False}, \
"unfogging the booth erased the poster's per-item blur"
def test_an_unreadable_booth_blur_marker_fogs_rather_than_reveals(tmp_path, monkeypatch):
"""`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 the poster asked to fog. Same shape, inverted safety, and the
inversion is the point.
Defeating change: `except OSError: return False`."""
import booth.items as items_mod
b = tmp_path / "g"
b.mkdir()
real = pathlib.Path.lstat
def boom(self, *a, **k):
if self.name == items_mod.BOOTH_BLUR_FILE:
raise PermissionError(13, "nope")
return real(self, *a, **k)
monkeypatch.setattr(pathlib.Path, "lstat", boom)
assert items_mod.is_booth_blurred(b) is True
def test_the_blurbooth_route_toggles_and_lands_back(tmp_path):
"""The POST target design-dev's header control needs, with `back=view` so
fogging from the review does not eject you from the review."""
b = tmp_path / "g"
b.mkdir()
(b / "a.png").write_bytes(b"x")
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blurbooth", data={"on": "1"}, follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/b/g/"
assert (b / ".blurbooth").exists()
r = c.post("/b/g/blurbooth", data={"on": "1", "back": "a.png"}, follow_redirects=False)
assert r.headers["location"] == "/b/g/view?f=a.png"
c.post("/b/g/blurbooth", data={"on": "0"}, follow_redirects=False)
assert not (b / ".blurbooth").exists()