Nearby rows bump verified_at on every save and deactivate rather than delete.
Unit edits are meeting fields only, gated by unit:write_own scoped to the unit
in the URL. Settings are a typed key registry falling back to code defaults;
find-a-unit now reads its source name and URL from it. link_url and contact
reject attribute-breakout characters and the nearby renderer escapes quotes.
Covered by tests/smoke_admin.py, 53 checks in-process.
The one string on this site where a deploy is the wrong latency is
"tonight's meeting is cancelled, the lot is flooded" at 4pm on a
Tuesday. That is a record with a lifecycle, not site copy, so it gets
a table and a write path rather than a commit.
store.py
announcements table, created by the existing IF NOT EXISTS path so
there is no migration. ends_at is REQUIRED: an announcement that
never expires is site copy, and site copy belongs in the repo where
it has a diff. Nothing is hard-deleted; taking one down early sets
revoked_at, so what the site said and when survives.
Ranking is urgent first, then most recent. Recency alone would let a
routine Wednesday notice bury a Tuesday cancellation still live.
Two caps, enforced here rather than in the route so the future panel
inherits them: 200 characters, and 3 live at once. Both reject rather
than truncate. Clipping a cancellation mid-sentence is worse than
making someone shorten it, and a 4th live notice is a signal nobody
is expiring things rather than something to render.
app.py
announcement_bar() above the sticky nav. Native <details>, no
JavaScript, which matters on a read-only rootfs with no build step.
Collapsed clamps to one line with a count; expanded lists all of
them and caps at 40vh. One notice renders with no chevron and no
count: the common case must not look like a widget.
FAILS OPEN. The admin API fails closed because it serves family
phone numbers. This is the opposite case, and a broken announcement
must never take down the public homepage.
Colours are the existing note and rust token pairs from the brand
standard. No new colour enters the palette.
admin_api.py
GET/POST/DELETE on /api/admin/announcements. Leads stay read-only;
announcements are the deliberate exception, because being mutable
and expiring is the entire feature rather than a guess at a process.
list returns a computed state per row (live, scheduled, expired,
revoked, over_cap) so "why is my notice not showing" is answerable
from the API and not from the homepage.
Two real leads on 2026-08-26 landed in the DB with no Sheet row and no mirror
row at all: the empty-env window meant the Sheet call never ran. Because
/mirrors/failed looked for state='failed', a mirror that was never attempted
read exactly like one that was never owed, and the summary reported a clean
failed_mirrors: {} while two families were missing from the sheet.
/join now writes a `pending` mirror row for every target BEFORE attempting any
of them, so the gap between "owed" and "done" is a row rather than an absence.
set_mirror_pending never downgrades an attempted mirror. failed_mirror_records
covers failed and pending, since both need replaying, and summary reports
pending_mirrors alongside failed_mirrors.
sheet_append also stamped the Sheet with datetime.now(), so replaying a lead
filed it under the day of the replay rather than the day the family submitted.
It now takes the submission timestamp off the record and only falls back to now
when there isn't one.
records was a leads table wearing a generic name. It carried display_name /
email / phone, which only mean anything for a lead, plus status / assigned_to /
notes, which were a guess at an outreach process that has not been designed.
Contacting a family is one-to-many, so three columns on the lead row was always
the wrong shape for it.
join_leads is one row per /join submission with one column per form field, two
timestamps, and payload holding the submission verbatim. Outreach gets its own
table when the process is actually known.
Also splits heard_from from heard_from_detail. app.py folded source_place /
source_other into a composed "Other: ..." string and threw the raw answer away,
which is the half that tells you which daycare the lead came from. The composed
value is still built for the Sheet and the ntfy push.
admin API: /records -> /leads, and PATCH is gone since a lead now has nothing
mutable on it. mirrors and meta are unchanged.
Form submissions now land in /data/scout73.db before anything leaves the
box. The Google Sheet and the ntfy push become mirrors whose per-record
outcome is recorded, so a failed sheet write is replayable instead of
surviving only as a push telling you to retype it.
The table is a generic record store keyed by 'kind', with workflow columns
and an audit trail, so RSVPs and other forms can land in the same place
later without a migration. Admin panel talks to /api/admin over HTTP and
never opens the DB file.
- app/store.py schema, writes, reads, one-time leads.jsonl backfill
- app/admin_api.py token-gated API; fails closed when ADMIN_TOKEN is unset
- app/app.py three hunks: imports, boot init, join_post
- compose ADMIN_TOKEN passed through from the Portainer stack env