Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6274073
docs(plans): plan the long-range weather outlook
prorochestvo Aug 21, 2026
77e0d0e
feat(weather): store the long-range daily forecast
prorochestvo Aug 21, 2026
b5d168f
feat(weather): fetch the 16-day forecast once a day
prorochestvo Aug 21, 2026
675d640
feat(weather): send a daily digest of the multi-week outlook
prorochestvo Aug 21, 2026
340033b
feat(weather): serve the multi-week outlook with the weather view
prorochestvo Aug 21, 2026
7413e3f
feat(wasm): show the 16-day outlook strip on the weather screen
prorochestvo Aug 21, 2026
4110dd9
docs(weather): record the long-range forecast canon
prorochestvo Aug 21, 2026
a48e656
fix(weather): treat a drained forecast window as missing data
prorochestvo Aug 21, 2026
7942a59
fix(lint): clear the findings on the new forecast code
prorochestvo Aug 21, 2026
e6cb2ff
fix(lint): assert the forecast-loader stubs against their interface
prorochestvo Aug 21, 2026
6140767
fix(weather): prune the outlook signature to the window
prorochestvo Aug 23, 2026
b1a0df9
fix(weather): bound the long-range decode to its horizon
prorochestvo Aug 23, 2026
0596205
refactor(weather): read the outlook window once per location
prorochestvo Aug 23, 2026
78e2c8b
refactor(weather): share one forecast-date formatter
prorochestvo Aug 23, 2026
2cb01e7
fix(wasm): derive the zero-state glyph from the domain enum
prorochestvo Aug 23, 2026
ede6eff
fix(weather): read one clock for the whole weather view
prorochestvo Aug 23, 2026
603df70
fix(weather): keep the weather view alive without a forecast
prorochestvo Aug 23, 2026
b98dd1c
test(weather): pin the forecast arrays to their date index
prorochestvo Aug 23, 2026
5b381ac
fix(weather): anchor the forecast window to the response
prorochestvo Aug 23, 2026
6cf837f
refactor(wasm): derive the zero-state class from the domain enum
prorochestvo Aug 23, 2026
7a8bef1
test(domain): guard the zero-state glyph and its wire token
prorochestvo Aug 23, 2026
35f5e1e
docs(weather): record the outlook pruning and the decode bounds
prorochestvo Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion .claude/skills/beacon-collection/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: beacon-collection
description: How Beacon's collector reaches upstreams and what it does with the results — per-source proxy opt-in and why direct is the default, batched sources sharing one fetch (the 20 Yahoo rows), the Open-Meteo weather provider with its retry policy and alert edge semantics, and the source-health alerting that reports a source gone silent. Load before touching cmd/collector, internal/tools/rateextractor, internal/application/collection, internal/infrastructure/weather, notification.SourceHealthAgent, any rate_sources row or seed migration, anything involving BEACON_PROXY_URL or options.use_proxy, weather alert kinds, or the rain/thaw/heat/frost latches.
description: How Beacon's collector reaches upstreams and what it does with the results — per-source proxy opt-in and why direct is the default, batched sources sharing one fetch (the 20 Yahoo rows), the Open-Meteo weather provider with its retry policy and alert edge semantics, the 16-day long-range forecast on its own daily gate and the content-gated outlook digest, and the source-health alerting that reports a source gone silent. Load before touching cmd/collector, internal/tools/rateextractor, internal/application/collection, internal/infrastructure/weather, notification.SourceHealthAgent, any rate_sources row or seed migration, anything involving BEACON_PROXY_URL or options.use_proxy, weather alert kinds, the rain/thaw/heat/frost latches, collection.WeatherForecastAgent, OpenMeteo.Forecast or ForecastRange, or the forecast_outlook notify kind and its notify_state signature.
---

# Beacon collection
Expand Down Expand Up @@ -120,6 +120,86 @@ waiting still fits inside `weatherGeoTimeout`, the 5 s deadline the Mini App cit
puts on the *same* client. Raising the budget means moving that deadline in the same change,
and `TestRetryScheduleFitsTheTightestCaller` is what says so out loud.

## The long-range forecast

`WeatherForecastAgent` (collector, its own runner beside `WeatherAgent`) stores 16 daily
rows per subscribed location in `weather_forecast_days`. Everything about it is deliberately
separate from the current-conditions path.

- **A separate HTTP request, not a wider one.** `OpenMeteo.ForecastRange` issues its own
call; `Forecast` and `decodeOpenMeteoForecast` are untouched. `Forecast` decodes daily
index `[0]`, and that index *is* today for the morning summary and for `alert_heat`,
`alert_frost`, `alert_thunderstorm` and `alert_thaw` — all four read `obs.TempMax` /
`TempMin` / `WeatherCode`. Widening the request or the decode risks shifting what those
five things mean with nothing to report it. The second request costs about one weighted
API call per location per day against a budget of 10,000. It still routes through
`OpenMeteo.get`, so it inherits the retry policy unchanged.
- **The decode is bounded, and the bound is not the clock.**
`decodeOpenMeteoForecastRange` truncates at `domain.WeatherOutlookHorizonDays` and drops
any date past a window measured from the response's *own* first date — plus an absolute
one-year ceiling, the only guard against a permanent row, since retention deletes the past
and nothing prunes the far future. Two invariants rest on this and neither is re-checked
downstream: the table has no archive tier because it is bounded at locations × 16, and a
whole fetch goes into one `BEGIN IMMEDIATE`, so an oversized response holds the WAL write
lock against the notifier and the web server for the length of the insert. The window is
anchored to the response rather than to `time.Now()` because forecast dates are city-local
while the clock is UTC — and on a host with no battery-backed RTC, a boot before time
synchronisation would otherwise filter a good response down to nothing. A `daily[]` that
yields no storable row is an **error**, never an empty success: reported as success it
leaves `captured_at` unmoved, so the daily gate never closes and the location is re-fetched
every tick behind a log line reading `fetched=1 failed=0`. This is the path a later
ensemble source swap would inherit.
- **The gate is a UTC calendar day, not 24 elapsed hours.** Against an hourly cron, "at
least 24 h since the last capture" drifts an hour later every day and eventually lands
after the subscriber's notify hour, so the digest would read a forecast a day older than
it needed to be. A calendar day pins the fetch to the first tick after midnight UTC.
- **Retention keeps one day of slack.** `RemoveForecastDaysBefore` runs on every tick
whatever the fetches did, with a cutoff of yesterday UTC: offsets run from −12 to +14, so
one extra day is what makes "past" unambiguous for every subscriber.
- **The units are not interchangeable.** Open-Meteo reports `rain_sum` in **millimetres**
and `snowfall_sum` in **centimetres**. A rain day is ≥ 1 mm, a snow day ≥ 1 cm; the two
thresholds are numerically equal and dimensionally different, so a single shared constant
is a bug.
- **The bar is not `> 0`.** Models smear small amounts across most days of a long-range run,
so at any trace above zero nearly every day of a 16-day window comes back wet.

## The outlook digest is content-gated, not latched

`forecast_outlook` is the one notify kind outside the latch model entirely: it is absent
from `alertKinds`, `UsesForecastDateCap` is false for it, and it never reaches
`EvaluateLatched`. A day two weeks out changes its mind several times before it arrives, so
a latch per condition would either send every flip or, with a dead band wide enough to stop
that, say nothing at all.

Instead `WeatherCheckAgent.runOutlookPhase` compares `domain.WeatherOutlook.Signature()`
against the stored `weather_user_cities.notify_state` *reduced to the days still ahead*, and
queues a message only when the two differ. Four properties are load-bearing:

- **The stored signature is pruned to today's window before it is compared or rendered**
(`domain.PruneWeatherOutlookSignature`); the freshly built one is stored unpruned. A
signature spans days strictly after the baseline and the baseline advances every morning,
so the day that becomes today leaves the new signature on its own, with nothing in the
forecast having changed. Compared raw, that reads as a change: the gate sends, and the diff
reports the arriving day as *cleared* — telling a reader on the morning it rains that the
rain day cleared. It also fires one message per roll-off day, which in a wet week is the
daily-whatever-happens digest the content gate exists to prevent. A second content-gated
kind modelled on this one inherits the trap.
- **The cursor advances on every evaluation that had data**, not only on a send. That is
what bounds the digest at one message per city per local day *regardless of how often the
collector refreshes the forecast underneath it* — a guarantee the fetch cadence must not
be able to take away by changing.
- **An empty signature means "never evaluated"** and is distinct from an evaluated outlook
with nothing in it, which encodes as the version prefix alone (`o1:`). The distinction is
what keeps a first digest from opening with "nothing to report".
- **A day is notable if it brings rain, snow, or a change in the freezing regime** relative
to the last classified day before it. Reporting every cold day would fill a Kazakh winter
digest with the fact that February is cold; what the reader needs is the day the regime
turns.

The `o1:` prefix on the signature exists so a future change to the encoding re-notifies
every subscriber exactly once rather than diffing two encodings that do not mean the same
thing. Bump it when the encoding changes meaning.

## Weather alert edge semantics

Every alert kind is edge-triggered through the per-row `alert_latched` boolean, and
Expand Down
29 changes: 28 additions & 1 deletion .claude/skills/beacon-http-api/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: beacon-http-api
description: Beacon's HTTP surface and browser client — endpoint contracts that are not obvious from the router code (chart period whitelist, weather city create validation, the forced alert rows and their 409, liveness vs readiness), the content-hashed WASM asset URLs and the nginx location ordering they depend on, and the Mini App's 2x2 screen navigation. Load before adding or changing anything under internal/gateway, cmd/web, cmd/wasm, cmd/web/static, configs/nginx.*, or any /api/v1/me or /api/v1/public route.
description: Beacon's HTTP surface and browser client — endpoint contracts that are not obvious from the router code (chart period whitelist, weather city create validation, the forced alert rows and their 409, the multi-week days array on /weather/current, liveness vs readiness), the content-hashed WASM asset URLs and the nginx location ordering they depend on, and the Mini App's 2x2 screen navigation. Load before adding or changing anything under internal/gateway, cmd/web, cmd/wasm, cmd/web/static, configs/nginx.*, dto.WeatherCurrentItem or WeatherForecastDayItem, the forecast_outlook subscription kind, or any /api/v1/me or /api/v1/public route.
---

# Beacon HTTP API and Mini App
Expand Down Expand Up @@ -69,6 +69,33 @@ deployment that must publish the port passes `--bind 0.0.0.0` explicitly.
Binding loopback is not what stops a *co-hosted* vhost reaching Beacon — that neighbour
proxies over loopback too. Only the port or the neighbour's upstream settles that.

## The multi-week outlook rides on `/current`

`GET /api/v1/me/weather/current` carries a `days` array per city: one entry per city-local
calendar day from today, ascending, at most `domain.WeatherOutlookHorizonDays` (16) of them.
There is no separate forecast endpoint and no second screen — the Mini App's navigation is a
2×2 matrix (below) and a fifth cell would break it, and one round trip beats two on a phone.

- **`days` is omitted, never sent as `[]`.** A location whose first long-range fetch has not
completed renders exactly as it did before the field existed.
- **It is independent of `has_data`.** The reading and the outlook are collected on
different cadences (hourly against once a day), so a city can hold either without the
other; the field is attached before the `has_data` gate for that reason.
- **The verdicts are the server's, not the client's.** `rain` and `snow` are booleans
resolved against the day thresholds (≥ 1 mm of rain, ≥ 1 cm of snowfall — different
units), `zero_state` is one of `above` / `crossing` / `below` / `""`, and `label` is the
date pre-formatted as `Sun 23 Aug`. All three are computed server-side so every client
draws the same badge from the same rule and the WASM bundle still needs no tzdata.
- **`""` for `zero_state` means the day carried no usable pair of temperature bounds.** It
is not a fourth category and must not be rendered as one.

The `forecast_outlook` subscription that turns this into a Telegram digest is **opt-in**,
unlike the two forced kinds below: it is listed in the manage screen's kind dropdown like
heat and frost. Its numeric input is the **hour picker**, not a threshold — it is timed
rather than thresholded, exactly like `morning_summary`. The 16-day *view*, by contrast,
appears for every tracked city whether or not the digest is subscribed, because collection
is driven by the distinct subscribed locations and is kind-agnostic.

## Forced weather subscriptions

`alert_thaw` and `rain_alert` are **forced, system-managed rows**. Creating any city
Expand Down
33 changes: 32 additions & 1 deletion .claude/skills/beacon-storage/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: beacon-storage
description: Beacon's SQLite storage rules beyond the basics — the hot/archive tiering of rate_values and execution_history (why one file, why reads UNION both tiers and writes touch only hot, roll-over, retention, VACUUM), the migrator contract and the immutable migration filename convention, columns that look droppable but are not, and how to read production data out of a gzipped snapshot. Load before writing or reviewing any query in internal/repository or internal/infrastructure/sqlitedb, adding or altering a migration under ./migrations, touching collection.MaintenanceAgent, sqlitedb.Migrator, Transaction/ReadOnlyTransaction, RetainRateSource, rate_source_health or weather_observations, or inspecting the production database.
description: Beacon's SQLite storage rules beyond the basics — the hot/archive tiering of rate_values and execution_history (why one file, why reads UNION both tiers and writes touch only hot, roll-over, retention, VACUUM), the migrator contract and the immutable migration filename convention, columns that look droppable but are not, why weather_forecast_days is bounded rather than tiered, why historical migration tests must not seed through a repository, and how to read production data out of a gzipped snapshot. Load before writing or reviewing any query in internal/repository or internal/infrastructure/sqlitedb, adding or altering a migration under ./migrations, touching collection.MaintenanceAgent, sqlitedb.Migrator, Transaction/ReadOnlyTransaction, RetainRateSource, rate_source_health, weather_observations, weather_forecast_days or RetainWeatherForecastDays, writing a test against stubSQLiteDBThrough, or inspecting the production database.
---

# Beacon storage
Expand Down Expand Up @@ -90,6 +90,37 @@ through `const` declarations (e.g. `rateSourceTableName`, `rateSourceNameFieldNa
schema rename surfaces at compile time and via `grep`, never via a runtime "no such column"
error.

### `weather_forecast_days` is bounded, not tiered

The long-range forecast table is a **bounded working set**, `locations × 16` rows, upserted
in place on the natural key `(location_id, provider, forecast_date)`. The tiering rule above
governs append-only telemetry and does not apply here: there is nothing an `*_archive` twin
could hold, no roll-over, and no reason for a read to union two branches. Do not "fix" that.

Three things about it that are decisions rather than omissions:

- **A whole fetch is one transaction.** `RetainWeatherForecastDays` writes all sixteen rows
under one `BEGIN`: a day's forecast is a single observation of the future, and the write
lock is taken at `BEGIN` (`_txlock=immediate`), so sixteen transactions would take and
release it sixteen times per location against three processes sharing the file.
- **Retention is keyed on `forecast_date`, never on `captured_at`.** Rows are superseded
while the day is still ahead and dropped once it is behind. A `captured_at` sweep — which
is what `weather_observations` uses — would delete a still-future day the moment its
location stopped being refreshed.
- **No foreign key to `weather_user_cities`.** A location whose last subscriber leaves stops
being refreshed and ages out within the horizon; cascading would tie the lifetime of
public meteorological data to one user's subscription row.

### Historical migration tests must not go through a repository

`weatherusercity_backfill_test.go` and `weatherusercity_backfillrain_test.go` exercise
migrations 021 and 026 against a snapshot of the schema **as it was when those migrations
were written** (`stubSQLiteDBThrough`), because both reference columns that later migrations
drop. Seeding or reading such a snapshot through `WeatherUserCityRepository` fails on every
column added afterwards: its SQL always names the current schema. Use
`seedHistoricalWeatherUserCity` / `obtainHistoricalWeatherUserCities` in `main_test.go`,
whose column list (`weatherUserCityEraColumns`) is frozen to that era on purpose.

### Two columns a migration must not "clean up"

- **`weather_observations.provider`** now only ever holds `'open-meteo'`, so it reads as
Expand Down
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ for its subject — this file only keeps the tripwire.

| Skill | Load before touching |
|---|---|
| `beacon-collection` | `cmd/collector`, `rateextractor`, `application/collection`, `infrastructure/weather`, `SourceHealthAgent`, `rate_sources` rows, `BEACON_PROXY_URL` / `options.use_proxy`, weather alert kinds |
| `beacon-storage` | any `internal/repository` query, any `./migrations/*.sql`, `MaintenanceAgent`, `sqlitedb.Migrator`, reading the production database |
| `beacon-collection` | `cmd/collector`, `rateextractor`, `application/collection`, `infrastructure/weather`, `SourceHealthAgent`, `rate_sources` rows, `BEACON_PROXY_URL` / `options.use_proxy`, weather alert kinds, `ForecastRange`, `forecast_outlook` |
| `beacon-storage` | any `internal/repository` query, any `./migrations/*.sql`, `MaintenanceAgent`, `sqlitedb.Migrator`, `weather_forecast_days`, reading the production database |
| `beacon-http-api` | `internal/gateway`, `cmd/web`, `cmd/wasm`, `cmd/web/static`, `configs/nginx.*`, any `/api/v1/me` or `/api/v1/public` route |
| `beacon-forecasting` | `internal/tools/rateforecaster`, `internal/tools/rateanomaly` (load with `knowledge:forecasting`) |
| `beacon-data-privacy` | any new column on a user-scoped table, anything captured from a Telegram update, any new log field |
Expand Down Expand Up @@ -83,6 +83,10 @@ proxied: `BEACON_PROXY_URL` says a proxy exists, `rate_sources.options.use_proxy
source wants it. No source is opted in today, and the default is a measured decision
(issue #16) — do not reverse it casually. Chromedp and weather stay direct regardless.

**Never widen `OpenMeteo.Forecast`'s `daily` block.** Its index `[0]` *is* today for the
morning summary and all four daily-metric latches. The multi-week fetch is a separate call
(`ForecastRange`, its own table, its own daily cadence) for exactly that reason.

> `cmd/doctor` is the operator-only umbrella for LLM rule (re)generation and source auditing (`rulegen` single/`--all`, `audit --all`/`--source`). Usage, exit codes, and env vars: `cmd/doctor/README.md` + godoc.

### Layer Responsibilities
Expand Down Expand Up @@ -179,6 +183,11 @@ rewrites those rows wholesale (`cmd/doctor rulegen` does exactly that), so a col
there is destroyed by an unrelated config write — which is why the source-health latch lives
in its own `rate_source_health` table.

**Long-range forecast rows belong in `weather_forecast_days`, never in
`weather_observations`**: the collector sweeps that table by `captured_at` at 48 h on every
tick, so a row describing a day two weeks out is gone a day and a half after it is written,
without an error anywhere.

**`rate_values` and `execution_history` are tiered.** Each has an `*_archive` twin in the
same file: reads must span both via `UNION ALL`, writes touch hot only. Getting this wrong
returns partial history without erroring. Schema lives at `./migrations/*.sql` and applied
Expand Down
Loading
Loading