Compare commits

..
6 Commits
Author SHA1 Message Date
hermes 8e4770f870 fix(ui): make the advice pop-out fail-safe against a stale stylesheet (#22)
Build and Push Image / Build and push image (push) Successful in 3m33s
fix(ui): make the advice pop-out fail-safe against a stale stylesheet (#22)
2026-11-09 22:41:44 +10:00
hermes fb1088ceec fix(ui): make the advice pop-out fail-safe against a stale stylesheet
The screenshot from Firefox showed the advice expanded inline in the
table cell, running down the column and off the screen, with the long
and short labels both showing ("BREWING ADVICEADVICE").  The page had
the new markup and Firefox was serving a stale stylesheet.

Two fixes.

The static files now send Cache-Control: no-store.  They sent
no-cache, which means "revalidate" - and Firefox was reusing them
without revalidating.  no-store is the only one that means "do not
keep this".  The HTML pages already sent no-store.

The pop-out carries the hidden attribute, and the script removes it
when the panel opens and puts it back when it closes.  That makes the
MARKUP fail-safe rather than the stylesheet: a stylesheet that does
not know about .advice-pop at all still leaves the panel hidden, so
the advice can never appear expanded inside the cell.

Verified by removing all eight .advice-pop rules from the live
stylesheet - the panel stays hidden, 0x0, and the advice is not
rendered.  Before this it would have rendered inline.

The button still opens it on tap and on hover, and the close button
still closes it.  The desktop is unchanged: the same full headings and
the same hover pop-out.

195 tests pass.
2026-11-09 22:41:20 +10:00
hermes cd45cc2f17 fix(advice): guard the advice write on the comment as well as the token (#21)
Build and Push Image / Build and push image (push) Successful in 3m10s
fix(advice): guard the advice write on the comment as well as the token (#21)
2026-11-09 22:27:01 +10:00
hermes e603f23928 fix(advice): guard the advice write on the comment as well as the token
The editing token catches every edit the application makes, because
upsert_reading writes a fresh token on every write.  A document edited
outside the application - by a script, or by hand - keeps its empty
token, so a stale thread's write would still match on the gravity alone
and could put advice about the old comment back.

The advice is generated from the comment as well as the gravity, so the
comment is now a second, independent guard on the write.  A stale write
can only land if BOTH the gravity and the comment are unchanged.

The stored comment and the one the view sees can differ by trailing
whitespace - two of the live readings are padded - so the match is a
stripped, anchored regex rather than an exact one.

Also: the log message said the reading 'no longer holds the gravity it
was written for', but the guard is the token and the comment.  It now
says what actually happened.

194 tests pass.
2026-11-09 22:26:29 +10:00
hermes a38febaca8 fix(advice): return matched_count, and do not advise about the wrong reading (#20)
Build and Push Image / Build and push image (push) Successful in 3m15s
fix(advice): return matched_count, and do not advise about the wrong reading (#20)
2026-11-09 22:16:46 +10:00
hermes ef0d665b78 fix(advice): return matched_count, and do not advise about the wrong reading
set_reading_advice returned modified_count, which is 0 both when the
guard refused the write and when the write matched but the text was
already identical.  The caller logs "the advice was not stored" on 0,
so it has to mean "the guard refused it".  It now returns
matched_count.

_start_advice fell back to the newest reading when the reading it was
asked about was not in the run.  That described the wrong number, and
the conditional write would refuse it anyway - so it now says so and
stops.

The editing token is the primary guard on the write, not the gravity.
Every edit writes a fresh token, so a comment-only correction - which
leaves the gravity unchanged - is caught too.  Verified against live
Mongo with the real repository code: the fresh advice lands, the slow
stale thread is refused, and the brewer's comment is untouched.

193 tests pass.
2026-11-09 22:16:29 +10:00
7 changed files with 191 additions and 6 deletions
+13
View File
@@ -97,6 +97,19 @@ def create_app(config: dict | None = None) -> Flask:
if response.mimetype == "text/html": if response.mimetype == "text/html":
response.headers["Cache-Control"] = "no-store, must-revalidate" response.headers["Cache-Control"] = "no-store, must-revalidate"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
elif response.mimetype in ("text/css", "application/javascript",
"text/javascript"):
# The static files must NEVER be reused from a cache.
# Firefox kept serving a stale stylesheet even with
# `no-cache`, which meant the page had the new markup and
# the old CSS: the advice was expanded inline in the table
# instead of in a pop-out, and the long and short labels
# were both showing ("BREWING ADVICEADVICE").
# `no-store` is the only one that means "do not keep this".
response.headers["Cache-Control"] = "no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response return response
csrf = CSRFProtect(app) csrf = CSRFProtect(app)
+35 -2
View File
@@ -18,6 +18,7 @@ Two collections are used:
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import os import os
import threading import threading
from typing import Any from typing import Any
@@ -232,7 +233,8 @@ class BeerRepository:
def set_reading_advice(self, reading_id: str, advice: str, def set_reading_advice(self, reading_id: str, advice: str,
ok: bool = True, ok: bool = True,
expected_sg: int | None = None, expected_sg: int | None = None,
expected_token: str | None = None) -> int: expected_token: str | None = None,
expected_comment: str | None = None) -> int:
"""Attach the LLM's advice to a reading. """Attach the LLM's advice to a reading.
The advice lives in its own field, so the brewer's own comment is The advice lives in its own field, so the brewer's own comment is
@@ -286,6 +288,31 @@ class BeerRepository:
# A direct call with a gravity to check against. # A direct call with a gravity to check against.
query["sg"] = str(expected_sg) query["sg"] = str(expected_sg)
if expected_comment is not None:
# The comment the advice was written for.
#
# This is a SECOND, independent guard. The token covers
# every edit the application makes, but a document edited
# outside the application - by a script, or by hand in a
# database tool - would keep its empty token and a stale
# thread's write would still match on the gravity alone.
#
# The advice is generated from the comment as well as the
# gravity, so the comment is part of what the advice
# describes. Matching on it means a stale write can only
# land if BOTH the gravity and the comment are unchanged.
#
# The stored comment and the one the view sees can differ by
# trailing whitespace, so a regex is used to match either.
#
# `^comment\s*$` matches the stored value with any trailing
# whitespace, and the exact value too. It is anchored, so it
# cannot match a longer comment that merely starts the same
# way.
query["comment"] = {
"$regex": "^" + re.escape(expected_comment.strip()) + r"\s*$"
}
# `$in` matches either form. A plain list would be an exact # `$in` matches either form. A plain list would be an exact
# ARRAY match in Mongo, which is not what is wanted here. # ARRAY match in Mongo, which is not what is wanted here.
result = self.readings.update_one( result = self.readings.update_one(
@@ -296,7 +323,13 @@ class BeerRepository:
"advice_at": _now_iso(), "advice_at": _now_iso(),
}}, }},
) )
return result.modified_count # `matched_count`, not `modified_count`.
#
# `modified_count` is 0 when the document matched but the text
# was already identical - which is a successful write, not a
# refused one. The caller logs "the advice was not stored" when
# this is 0, so it has to mean "the guard refused it".
return result.matched_count
def set_final_reading(self, beer_run_id: str, reading_date: str, def set_final_reading(self, beer_run_id: str, reading_date: str,
final: bool) -> int: final: bool) -> int:
+11
View File
@@ -999,10 +999,21 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
/* The script opens and closes the panel, and places it. The CSS only /* The script opens and closes the panel, and places it. The CSS only
decides whether it is shown. */ decides whether it is shown. */
/* The panel is opened by the script, which removes the `hidden`
attribute and adds `is-open`.
The `hidden` attribute is what makes this fail-safe: if the
stylesheet is stale and does not know about `.advice-pop` at all,
the panel is STILL hidden, so the advice cannot appear expanded
inside the table cell. */
.advice-pop.is-open { .advice-pop.is-open {
display: block; display: block;
} }
.advice-pop[hidden] {
display: none;
}
/* On a phone there is no hover, so the trigger is a button the script /* On a phone there is no hover, so the trigger is a button the script
toggles, and the panel is pinned to the middle of the screen so it is toggles, and the panel is pinned to the middle of the screen so it is
readable whatever the table's width. */ readable whatever the table's width. */
+8
View File
@@ -131,6 +131,11 @@
clearTimeout(closeTimer); clearTimeout(closeTimer);
closeTimer = null; closeTimer = null;
} }
/* The `hidden` attribute is removed as well as the
class added, so the panel is shown even if the
stylesheet is an older one that only knows about
the class. */
panel.removeAttribute("hidden");
panel.classList.add("is-open"); panel.classList.add("is-open");
trigger.setAttribute("aria-expanded", "true"); trigger.setAttribute("aria-expanded", "true");
place(); place();
@@ -138,6 +143,9 @@
function close() { function close() {
panel.classList.remove("is-open"); panel.classList.remove("is-open");
/* Put the attribute back, so a stale stylesheet
cannot leave the advice expanded in the cell. */
panel.setAttribute("hidden", "");
trigger.setAttribute("aria-expanded", "false"); trigger.setAttribute("aria-expanded", "false");
} }
+1 -1
View File
@@ -126,7 +126,7 @@
{% endif %} {% endif %}
</span> </span>
</button> </button>
<div class="advice-pop" role="tooltip"> <div class="advice-pop" role="tooltip" hidden>
<div class="advice-pop__head"> <div class="advice-pop__head">
<span class="advice__label">Brewing advice</span> <span class="advice__label">Brewing advice</span>
<button type="button" class="advice-pop__close" <button type="button" class="advice-pop__close"
+17 -3
View File
@@ -106,7 +106,15 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
# wrong number. # wrong number.
saved = next((r for r in readings if r.get("_id") == str(reading_id)), None) saved = next((r for r in readings if r.get("_id") == str(reading_id)), None)
if saved is None: if saved is None:
saved = dict(readings[-1]) if readings else {"date": "", "sg": None} # The reading is not in the run. Advising about the newest
# reading instead would describe the wrong number, and the
# conditional write would then refuse it anyway - so it is
# better to say so plainly and stop.
log.info(
"no advice for reading %s: it is not one of the run's readings",
reading_id,
)
return
latest = dict(saved) latest = dict(saved)
latest["_id"] = reading_id latest["_id"] = reading_id
@@ -119,6 +127,11 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
# cannot overwrite a correction with advice about the old value. # cannot overwrite a correction with advice about the old value.
advice_sg = latest.get("sg") advice_sg = latest.get("sg")
advice_token = latest.get("advice_token") advice_token = latest.get("advice_token")
# The comment is part of what the advice describes, so it is a
# second, independent guard on the write. The token covers every
# edit the application makes; this also covers a document edited
# outside it.
advice_comment = latest.get("comment") or ""
def write(read_id: str, ok: bool, text: str) -> None: def write(read_id: str, ok: bool, text: str) -> None:
# The client is SHARED, not built per reading. A MongoClient # The client is SHARED, not built per reading. A MongoClient
@@ -137,6 +150,7 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
read_id, text, ok, read_id, text, ok,
expected_sg=advice_sg, expected_sg=advice_sg,
expected_token=advice_token, expected_token=advice_token,
expected_comment=advice_comment,
) )
if not written: if not written:
# The reading was corrected while the advice was being # The reading was corrected while the advice was being
@@ -145,8 +159,8 @@ def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None
# reading will land in its own turn. # reading will land in its own turn.
log.info( log.info(
"the advice for reading %s was not stored: the reading " "the advice for reading %s was not stored: the reading "
"has been edited since the advice was generated, so the " "has been edited since the advice was generated, so its "
"advice no longer matches it", "editing token or comment no longer matches the advice",
read_id, read_id,
) )
except Exception: # noqa: BLE001 - never let the thread die noisily except Exception: # noqa: BLE001 - never let the thread die noisily
+106
View File
@@ -1079,6 +1079,112 @@ def test_the_long_and_short_labels_are_both_in_the_page(client, repo):
assert '<span class="short">Advice</span>' in body assert '<span class="short">Advice</span>' in body
def test_stale_advice_cannot_overwrite_a_comment_only_correction(repo):
"""A comment-only correction still invalidates the old advice.
The gravity alone is not enough to tell two generations apart - a
comment-only correction leaves the gravity unchanged. The editing
token is what tells them apart, because every edit writes a fresh
one.
"""
repo.readings.insert_one({
"_id": "readingone",
"beer_run_id": "1",
"date": "2026-01-01",
"sg": "1040",
"comment": "original comment",
"final_reading": "False",
"advice_token": "token-one",
})
# The slow thread holds the token it was given.
stale_token = "token-one"
# The brewer corrects only the comment.
repo.readings.update_one(
{"_id": "readingone"},
{"$set": {"comment": "corrected comment", "advice_token": "token-two"},
"$unset": {"advice": "", "advice_ok": "", "advice_at": ""}},
)
# The fresh advice lands first, carrying the new token.
assert repo.set_reading_advice(
"readingone", "FRESH ADVICE", ok=True, expected_token="token-two") == 1
# The slow thread lands afterwards, carrying the old token. It must
# be refused.
assert repo.set_reading_advice(
"readingone", "STALE ADVICE", ok=True, expected_token=stale_token) == 0
doc = repo.readings.find_one({"_id": "readingone"})
assert doc["advice"] == "FRESH ADVICE"
assert doc["comment"] == "corrected comment"
def test_stale_advice_cannot_overwrite_a_comment_edited_outside_the_app(repo):
"""The comment is a guard in its own right, not just the token.
The token covers every edit the application makes. A document
edited outside it - by a script, or by hand - keeps its empty
token, so a stale thread's write would still match on the gravity
alone. Matching the comment as well closes that.
"""
repo.readings.insert_one({
"_id": "outsideedit01",
"beer_run_id": "1",
"date": "2026-01-01",
"sg": "1040",
"comment": "original comment",
"final_reading": "False",
# No advice_token - a legacy reading.
})
# The advice is written for the original comment.
assert repo.set_reading_advice(
"outsideedit01", "FRESH ADVICE", ok=True,
expected_token="", expected_comment="original comment") == 1
# Somebody edits the comment directly in the database, leaving the
# token empty - so the token cannot tell the generations apart.
repo.readings.update_one(
{"_id": "outsideedit01"},
{"$set": {"comment": "edited outside the app"}},
)
# The slow thread lands. It must be refused, even though the token
# still matches.
assert repo.set_reading_advice(
"outsideedit01", "STALE ADVICE", ok=True,
expected_token="", expected_comment="original comment") == 0
doc = repo.readings.find_one({"_id": "outsideedit01"})
assert doc["advice"] == "FRESH ADVICE"
assert doc["comment"] == "edited outside the app"
def test_the_advice_pop_out_is_hidden_in_the_markup(client, repo):
"""The pop-out carries the `hidden` attribute.
A stale stylesheet - one that does not know about `.advice-pop` at
all - would otherwise leave the advice expanded inline in the table
cell, running down the column and off the screen. The `hidden`
attribute makes the markup fail-safe: the panel stays hidden until
the script opens it.
"""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "")])
doc = repo.readings.find_one({"beer_run_id": "1"})
repo.set_reading_advice(str(doc["_id"]), "Warm it to 21C and wait.", ok=True)
body = client.get("/brews").get_data(as_text=True)
assert 'class="advice-pop"' in body
assert 'hidden' in body.split('class="advice-pop"')[1][:60]
def test_a_failed_advice_is_marked_as_such(client, repo): def test_a_failed_advice_is_marked_as_such(client, repo):
"""A generation failure reads differently from real advice.""" """A generation failure reads differently from real advice."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"}) repo.users.insert_one({"username": "beer", "password": "beer_pass"})