Companion to MAP_OPTIONS.md (what a closure is) and REPORT_A_PROBLEM.md (what a report is). This document owns the delivery path for both: how public safety data gets from the moderation queue onto a phone, and why that path should not run through an always-on server.
It does not restate the closure or report designs, and it does not own hosting — backend/HOSTING.md does, and its "Revisit if" clause names this document as the thing that would change its answer.
Today GET /closures is served by the FastAPI backend. That makes the safety-critical
read path depend on the single most fragile component in the system: one container, on one
host, on one account, on one person's card.
That is not primarily a cost problem — it is about $2/month, and #393 is emphatic that the host line is the wrong thing to optimize. It is a values problem, and three of them:
- #4 Trustworthy above all. Today, if the backend is unreachable, a hiker gets no
closure warnings and no indication that is why.
App.tsxholds closures asClosureSummary[] | nulland the null branch renders nothing. "No closures" and "could not ask" are indistinguishable on screen — the same ambiguity #249 records for maintainer assignments, on the readApp.tsx:508-510calls "the half a hiker walks into." - #8 Sustainable. "Design so the project can survive turnover in maintainers." A lapsed card silently removes closure warnings for everyone.
- #3 Open by default / #7 Built to be inherited. A club forking this inherits the code but not the running service. The safety data arrives only if they stand up a backend.
Worth noting this is a return, not a departure: TECHNICAL_ARCHITECTURE.md's Hosting section already calls the backend "a real, deliberate cost/complexity increase over the original 'no servers to run' MVP framing."
moderation_status == verified is the line the public/private split already runs along.
closures.py's docstring: browsing needs no account; reports.py filters public queries
on the same flag.
So everything a hiker reads without signing in is already public, unauthenticated and read-mostly — which is the exact shape of the trail data this project already serves brilliantly as static bytes on R2 with free egress. The safety data is being served dynamically because it happens to live in Postgres, not because it needs to be.
Split by read versus write, not by feature. The read path becomes a published artifact; the write path stays on the backend and becomes latency-tolerant.
A new artifact family published by the existing pipeline, alongside the trail exports:
conditions/closures.json verified closures
conditions/reports.json verified, publicly-visible reports
conditions/atc_updates.json the ATC's own trail notices
The third arrives by a different road and ATC_TRAIL_UPDATES.md owns
why: it is baked from a file in git that a person reviewed, not queried out of the database,
so it needs no credential and carries a reviewed_at alongside the generated_at the other
two make do with. Everything below about delivery applies to it unchanged.
All three referenced from latest.json with a sha256, exactly like every other artifact —
pipeline/publish.py (399 lines, with lib/r2_keys.py enforcing the key rules) already
does this and needs an artifact added, not a mechanism built. That matters for value #8's
preference for boring technology: this is not new machinery.
Fields the bake must drop: reported_by and verified_by. They are Supabase auth
user ids — stable identifiers tying a person to a place and a time. SAYING_THANKS.md
declines to publish that a named volunteer is at a known place on a predictable schedule
without consent, and a published artifact is the most permanent form of publishing
available here.
Separately and more urgently: ClosureOut currently returns both fields on the
unauthenticated GET /closures. That is a live question about the existing endpoint, not
something this design introduces, and it wants its own issue rather than a quiet fix
buried in this one.
Fields kept: id, trail_id, start_mile_marker, end_mile_marker, reason_type,
note, status, closed_since, expected_reopen, reroute_url, verified_at.
Reports track what ReportOut.for_viewer sends an anonymous caller (#436), which is
why the artifact has no reporter_id, received_at, maintainer_id or club_id — those
are withheld from anonymous responses — and no verified_by/verified_at, which the
public report schema never carries at all.
photo_url is absent from the reports artifact, decided rather than deferred
(#436, option one of the three it weighed): the live endpoint answers it with a
presigned URL whose expiry is measured in minutes, against a private bucket, and a baked
artifact is rewritten daily — a published signature would be broken by the time it was
read, and a long-lived one would defeat the private bucket. So the baseline supplies the
warning and the live tier supplies the photo; a baseline report simply renders without
one. Revisit only if field testing shows a photo changes what a hiker does about a
warning, and then via the stable-indirection option, not a longer signature.
A scheduled job reads verified rows from Postgres and writes the artifacts — the same
shape as publish-vector-data.yml, on a daily schedule.
Daily is the maintainer's stated tolerance (2026-08-08): "a closure can be latent by a day. we won't be adjusting minute by minute." Recorded here because it is the number the whole design rests on, and because a future reader will otherwise assume it was guessed.
It needs a read-only database credential, distinct from the two that already exist.
The repository already draws this distinction twice — *_MIGRATION_DATABASE_URL is not
DATABASE_URL, and the report-photo credentials carry an R2_PHOTO_ prefix precisely so
they cannot be confused with the publishing ones. A job that only ever reads verified rows
should hold a credential that can only do that. Declared as
PRODUCTION_CONDITIONS_DATABASE_URL in .github/expected-settings.yml.
And it needs an RLS policy, which is the part that would otherwise be found the hard
way. enable_row_level_security turns RLS on for every table with no policies, and
its own docstring explains why the backend is unaffected: "RLS does not apply to a
table's owner, and the backend connects with the Postgres connection string as the
owner." A reader role is not the owner. So GRANT SELECT alone returns zero rows
rather than an error — which, published unchecked, is an empty closures artifact, a
client treating it as a valid baseline, and hikers shown no closure warnings. A
permissions mistake wearing the costume of a quiet trail.
That migration anticipated this: "Add policies if and only if something is later built that genuinely needs direct table access." This is that case, and the policy earns its keep twice — it is also where the moderation filter belongs, so the database refuses to show the publisher anything unverified and a buggy exporter structurally cannot leak an unmoderated row:
CREATE ROLE ourhike_conditions_reader LOGIN PASSWORD '<generated>';
GRANT CONNECT ON DATABASE postgres TO ourhike_conditions_reader;
GRANT USAGE ON SCHEMA public TO ourhike_conditions_reader;
-- Two tables. Deliberately not `profiles`: the artifact names nobody (#430),
-- and no grant means the exporter could not resolve a person if it tried.
GRANT SELECT ON public.closures, public.reports TO ourhike_conditions_reader;
CREATE POLICY conditions_reader_closures
ON public.closures FOR SELECT TO ourhike_conditions_reader
USING (moderation_status = 'verified');
-- Reports differ from closures: `status` + `visibility`, mirroring
-- `_MODERATED_STATUSES` and excluding internal_only and club_only.
CREATE POLICY conditions_reader_reports
ON public.reports FOR SELECT TO ourhike_conditions_reader
USING (status IN ('verified', 'resolved') AND visibility = 'public');String literals rather than enum casts because those columns are native_enum=False, so
they are VARCHAR(20).
The connection string's username must be tenant-qualified. Supabase's pooler routes on
the part after the dot, so it wants ourhike_conditions_reader.<project-ref>, not the bare
role name. A bare role is refused at connect with
FATAL: (ENOTFOUND) tenant/user ... not found — a message that reads like the role was
never created, and sends you back to re-check SQL that is already correct. The first real
run of publish-conditions.yml failed on exactly this (2026-08-08); export_conditions.py
now catches it and says so rather than passing the raw error through.
export_conditions.py refuses to run unless both are in place, asking the catalog
rather than trusting the configuration, so that a genuinely empty result is trustworthy.
pipeline/tests/test_export_conditions.py proves the underlying trap is real by
reproducing it against a live Postgres — a verified closure that reads fine, then reads as
nothing the moment RLS is on without a policy.
Two tiers, and the second is optional:
- Baseline — fetch
conditions/closures.jsonfrom R2 at startup, with the map data. No account, no backend, CDN-backed, free egress. Always available. - Live refresh — if
VITE_API_BASE_URLis set and reachable,GET /closuresfor fresher data, overlaid on the baseline.
The state model has to change, and this is the part that is not optional. Closures stop
being ClosureSummary[] | null and become three distinguishable states:
| State | Meaning | What a hiker sees |
|---|---|---|
live |
fetched from the backend just now | closures, no staleness note |
baseline |
from the published artifact | closures, "as of " |
unavailable |
neither reachable | an explicit "conditions unavailable" |
That third row is the #249 fix, and the second row is value #4 doing real work — "honesty about uncertainty (e.g., 'reported 3 days ago' vs 'confirmed today')." A staleness stamp is not decoration here; it is the feature that makes a day-old baseline trustworthy instead of misleading.
Note the failure mode this removes: today, an unreachable backend means no warnings at all. With a baseline, the worst case is day-old warnings, labelled as day-old. That is strictly safer.
Once closures come from R2, the safety argument for min_machines_running = 1 is gone,
because nothing a hiker reads on the trail touches the backend any more. What remains is
latency-tolerant by construction:
- Report submission already queues in an offline outbox with its authored timestamp. A cold start is invisible to it.
- Moderation is a handful of trusted people doing deliberate work at a desk.
- Photo loads tolerate a few seconds.
So the backend can scale to zero, and the free tiers HOSTING.md ruled out come back into scope. This is a consequence, not the goal — the saving is ~$2/month, and chasing that would be exactly the mistake #393 warns about.
- All writes —
POST /closures,POST /reports, the role-gatedPATCH. _visible_to— the unmoderated/private view. This does not move into SQL policies. #393 rejected PostgREST-with-RLS precisely because that rule has drifted once already when it lived in two places. This design shrinks the surface it governs rather than relocating it: everything public is baked, so_visible_toonly ever answers for authenticated, unmoderated reads.- Photo presigning — needs the R2 signing key, which cannot live on a phone.
- Per-user private state — profiles, preferences, hikes.
GET /moderation/queue.
- Up to a day of staleness, accepted above.
- A dismissed closure lingers. If a moderator dismisses a verified closure, the baseline keeps showing it until the next bake. The live tier corrects it whenever the backend is reachable, and dismissal-after-verification is rarer than creation — but this is the one case where the baseline is wrong rather than merely old, and it should be named rather than discovered.
- A new scheduled job, with a credential and a failure mode of its own. A bake that silently stops produces a baseline that ages without saying so — so the artifact carries its own generation timestamp, and the client renders it. Staleness must be visible in the data, not inferred from the schedule.
- It removes no vendor. Supabase, R2 and a (now scale-to-zero) host all remain.
A fork points at its own R2 bucket and the safety read path works, with no account handoff and no backend at all. That is #3 and #7 implemented rather than aspired to — and it is the thing switching hosts could never have delivered.
The ordering is load-bearing: do not relax min_machines_running until the baseline is
actually being served and read. Until then the always-on machine is the only thing
delivering closures.
- Bake and publish
conditions/closures.json(pipeline + scheduled workflow + read-only credential). Built.pipeline/export_conditions.pyand.github/workflows/publish-conditions.yml; the workflow skips with a warning until the credential above exists, so nothing here is waiting on it. - Client reads the baseline, with the three-state model and the staleness stamp. Built (#435).
- Extend to
conditions/reports.json. Built (#436):export_conditions.pybakes both artifacts and refuses to run unless both tables' grants and policies are in place, so a half-configured database publishes nothing rather than one artifact of two. The client holds reports in the same three-state model as closures, and the status strip's one conditions line reports the worst of the two states — a live closures read next to unreachable reports is a map silently missing its warning pins, and no caveat would claim a completeness the screen does not have. - Only then revisit hosting — HOSTING.md's "Revisit if" clause, with its own decision.
The privacy question in §1 about ClosureOut is not in this sequence deliberately: it
concerns the endpoint as it exists today and should not wait for any of this.