diff --git a/ROADMAP.md b/ROADMAP.md index f4c9267..1396dee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,7 +105,7 @@ Where it already binds, and what the rule is in each case: | the Desk's sections | fixed: needs you → new since you looked → everything else (R2) | | within *needs you* | `(open_since, name)` | | within *new since you looked* | `(-landed_at, name)` | -| within *everything else* | `list_booths` order: `(mtime, name)` descending | +| within *everything else* | `(-landed_at, name)`: last updated first, the date each row shows. Was `list_booths`' activity order, which counted a look (operator, 2026-09-23) | | the Desk's bookmarks column | `order_for_display` — pinned first, then newest | | caption sidecar resolution | sorted scan, so two media files sharing a stem resolve the same way every time (a real non-determinism U1 removed) | | marks in a booth | `(created, id)` — time, with the id as tie-break so two marks written in the same second cannot swap | diff --git a/booth/app.py b/booth/app.py index a0311e7..4a36047 100644 --- a/booth/app.py +++ b/booth/app.py @@ -1159,8 +1159,12 @@ def create_app( booth that has one; name breaks ties. new — content landed since the booth was last looked at, or never looked at. Newest content first; name breaks ties. - rest — everything else, in `list_booths`' own order (last activity - first, name as the tie-break). No second rule is stated. + rest — everything else, last UPDATED first: `(-landed_at, name)`, + the date the row shows. Not `list_booths`' activity order: + that counts a look, so opening a booth moved it up and a + script fetching every booth reshuffled the section into + reverse name order (operator, 2026-09-23: "last activity can + just be last time the booth was updated"). The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so the lanes sorted nothing. Kept status still shows on every row. """ @@ -1176,6 +1180,7 @@ def create_app( in_new = {b["name"] for b in new} rest = [b for b in everything if b["name"] not in in_needs and b["name"] not in in_new] + rest.sort(key=lambda b: (-b["landed_at"], b["name"])) benches, benches_error = read_benches(data_dir) board = data_dir / links_board bookmarks = [row for row in _board_rows(board) diff --git a/booth/templates/index.html b/booth/templates/index.html index 0c3f74f..a883a52 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -100,7 +100,7 @@ {% endif %} {% if rest %}
-

Everything else last activity first

+

Everything else last updated first

{% for b in rest %}{{ row(b, 'rest') }}{% endfor %}
{% endif %} diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index e64bbc2..c5c0305 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -6,7 +6,7 @@ purpose: "Make the Booth a place where judgment happens rather than a place wher depends_on: - "booth.items.booth_items + Item (INV-1: the one resolver). Item gains `ordinal`, derived there and nowhere else." - "booth.items.image_chain (the zoom ring). SUPERSEDED for the review route by `review_chain`; image_chain stays importable and unchanged for its existing callers and tests." - - "booth.app._newest_mtime (THE definition of activity — booth-dev, 2026-09-23). The Desk's 'last activity' reuses it verbatim. The Desk's 'landed since you looked' is a DIFFERENT question and gets a DIFFERENTLY NAMED helper; see INV-5." + - "booth.app._newest_mtime (THE definition of activity — booth-dev, 2026-09-23). It feeds lifetime; the Desk no longer sorts by it (amended 2026-09-23, §3). The Desk's 'landed since you looked' is a DIFFERENT question and gets a DIFFERENTLY NAMED helper; see INV-5." - "booth.app.record_view / VIEW_MARKER (`.viewed`, U4). The Desk reads its mtime to answer 'new since you looked'." - "booth.app.hold_read / hold_reason / open_marks (INV-2 of U2: the one openness predicate). 'Needs you' is `open_marks(...)` non-empty, or `hold_reason(...) == \"unreadable\"` (C4); nothing else." - "booth.marks.as_dict, set_flag, write_note, answer_pick, delete_mark (the write API, UNCHANGED)." @@ -292,13 +292,18 @@ rule — a second renderer in JavaScript would be the same bug in a new language booth that has one. 2. **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. -3. **Everything else** — in `list_booths`' own existing order: `(mtime, name)` - descending, where `mtime` is today's `_newest_mtime`. That is last activity - first, with name as the tie-break (`test_the_index_order_has_a_tie_breaker` - pins 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). +3. **Everything else** — last UPDATED first: `(-landed_at, name)`, the date + the row shows as "updated". **Amended 2026-09-23 by the operator** ("last + activity can just be last time the booth was updated, not necessarily + operator's last activity"). This section used to be `list_booths`' order, + `(mtime, name)` descending over `_newest_mtime`, and that clock counts a + look: opening a booth moved it up, and a script that fetched every booth + (a post-deploy check) collapsed the whole section into reverse name order. + - Flagging, viewing or blurring a booth no longer moves it. Only content + does, which is also what moves a booth into (2). + - `list_booths` keeps its own `(mtime, name)` order for its other readers, + and `_newest_mtime` still feeds lifetime (INV-5). Only the Desk's + section stopped reading it. The side column holds: @@ -469,7 +474,7 @@ path) is a 404, as any other unknown rel is — never a 500. | Desk sections | fixed: needs → new → everything | | needs you | `(open_since, name)` | | new since you looked | `(-landed_at, name)` | - | everything else | `list_booths` order: `(mtime, name)` descending | + | everything else | `(-landed_at, name)`: last updated first (amended 2026-09-23) | | bookmarks | `order_for_display` | The notes list keeps `(created, id)`. @@ -490,7 +495,8 @@ path) is a 404, as any other unknown rel is — never a 500. is specified in C3 and is the one declared exception. - **INV-5 — two named clocks.** - `mtime` / `_newest_mtime`: activity. It includes dotfiles and excludes - locks, and it feeds lifetime and 'everything else'. + locks, and it feeds lifetime. (It fed 'everything else' until 2026-09-23; + see §3.) - `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 diff --git a/tests/mutations/r2_flow.toml b/tests/mutations/r2_flow.toml index f203dc5..f297de0 100644 --- a/tests/mutations/r2_flow.toml +++ b/tests/mutations/r2_flow.toml @@ -210,3 +210,21 @@ old = ''' new = ''' var word = WORDS[form.getAttribute('data-confirm')]; if (word && !confirm(''' + +[[mutation]] +label = "everything else in activity order again (a look moves a booth up)" +file = "booth/app.py" +test = "tests/test_flow.py::test_everything_else_is_ordered_by_last_update_not_by_looking" +old = ''' + rest.sort(key=lambda b: (-b["landed_at"], b["name"]))''' +new = ''' + pass''' + +[[mutation]] +label = "everything else breaks an update tie by name reversed" +file = "booth/app.py" +test = "tests/test_flow.py::test_everything_else_breaks_an_update_tie_by_name" +old = ''' + rest.sort(key=lambda b: (-b["landed_at"], b["name"]))''' +new = ''' + rest.sort(key=lambda b: (b["landed_at"], b["name"]), reverse=True)''' diff --git a/tests/test_flow.py b/tests/test_flow.py index b01053a..3b66a50 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -243,6 +243,36 @@ def _desk(body: str) -> dict[str, list[str]]: return out +def test_everything_else_is_ordered_by_last_update_not_by_looking(tmp_path): + """Operator, 2026-09-23: "last activity can just be last time the booth was + updated". The row shows "updated X ago" (`landed_at`), but the section + sorted by `_newest_mtime`, which counts a look, so opening a booth moved it + up, and a script that fetched every booth reshuffled the whole section into + reverse name order. Defeating change: `rest` left in `list_booths` order.""" + t0 = time.time() - 100_000 + for name, landed in (("aaa-old", t0), ("mmm-mid", t0 + 250), ("zzz-new", t0 + 500)): + b = _booth(tmp_path, name, {"a.png": PNG}) + _at(b / "a.png", landed) + (b / ".viewed").write_bytes(b"") + _at(b / ".viewed", t0 + 1000) # every one looked at since it landed + _at(b, t0) + _at(tmp_path / "aaa-old" / ".viewed", t0 + 5000) # ...and the OLDEST looked at last + body = _client(tmp_path).get("/").text + assert _desk(body)["rest"] == ["zzz-new", "mmm-mid", "aaa-old"] + assert "last updated first" in body + + +def test_everything_else_breaks_an_update_tie_by_name(tmp_path): + """CLAUDE.md invariant 6: two booths landed by one rsync share an mtime.""" + t0 = time.time() - 100_000 + for name in ("bravo", "alpha", "charlie"): + b = _booth(tmp_path, name, {"a.png": PNG}) + _at(b / "a.png", t0) + (b / ".viewed").write_bytes(b"") + _at(b / ".viewed", t0 + 10) + assert _desk(_client(tmp_path).get("/").text)["rest"] == ["alpha", "bravo", "charlie"] + + def test_the_desk_triages_needs_you_then_new_then_everything_else(tmp_path): """The tracer for C4: three sections, always in this order, each booth in exactly one of them."""