Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 35 additions & 1 deletion .claude/skills/beacon-collection/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
---
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, 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.
description: Beacon's collection pipeline — source kinds (BID/ASK/LAST) and the options JSON column, per-source proxy opt-in (direct by default; the Telegram bot always bypasses), batched sources sharing one fetch, the Open-Meteo client and its retry policy, the 16-day forecast and its daily gate, the content-gated outlook digest, weather alert latches, source-health alerting, and the cmd/doctor operator umbrella. Load before touching cmd/collector, cmd/doctor, internal/tools/rateextractor or proxyutil, internal/application/collection, internal/infrastructure/weather or telegrambot, any rate_sources row or seed migration, domain.RateSourceOptions, WeatherForecastAgent, BEACON_PROXY_URL or options.use_proxy (HTTPS_PROXY/HTTP_PROXY/NO_PROXY do nothing here), BEACON_CHROMIUM_PATH or fetcher_kind='chromedp' sources, the rain/thaw/heat/frost alert kinds, or the forecast_outlook notify kind.
---

# Beacon collection

Everything the collector fetches, and what it does with the answer.

## Source rows: `kind` and `options`

A `rate_sources` row carries a `kind` of `BID`, `ASK`, or `LAST` (equity / last-traded
price). Per-source fetch behaviour — header overrides, the proxy opt-in — lives in the
`options` JSON column, typed as `domain.RateSourceOptions`.

Several sources may share one URL and therefore one fetch; that batching is load-bearing and
easy to break — see "Batched sources share one fetch" below.

## Egress: direct by default, per-source opt-in

`cmd/collector` reaches every upstream directly unless a source row opts out. Two levels
Expand Down Expand Up @@ -34,6 +43,16 @@ worth not reversing casually:
`cmd/doctor` still honours the proxy unconditionally: it talks to AI providers, which is a
different question with different exposure.

**Telegram Bot API traffic bypasses any proxy unconditionally**, and does so in code rather
than by configuration: a hardcoded `Proxy: nil` transport in
`internal/infrastructure/telegrambot/tbotclient.go`. No env var can route the bot through a
proxy, which is deliberate — the bot is the channel that reports collection failures, so it
must not share a failure mode with the thing it reports on.

The standard `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` variables are consulted by **no**
component in this project; `BEACON_PROXY_URL`, resolved through `proxyutil.ResolveURL`, is the
only knob. Setting the standard ones changes nothing and looks like it should.

Two things stay direct regardless of the flag. **Chromedp** takes its proxy as a
browser-launch argument on one Chromium subprocess shared by the whole tick, so it cannot
vary per source — `NewRateAgent` therefore passes it an empty proxy URL on purpose, or a
Expand Down Expand Up @@ -260,3 +279,18 @@ stopped anyone hearing about it.
180 days.
- Weather locations are **not** covered: they write no `execution_history`, so there is no
persisted per-location outcome to measure a gap against.

## Operator tooling: `cmd/doctor`

`cmd/doctor` is the operator-only umbrella for LLM rule (re)generation and source auditing:
`rulegen` for a single source or `--all`, and `audit --all` / `audit --source <name>`. No
service binary calls it — it runs by hand or from cron, which is why it may take liberties
(the unconditional proxy above) that the collector may not.

Usage, exit codes, the AI DSN formats and the chromedp/Chromium setup live in
`cmd/doctor/README.md` and the package godoc; `make audit`, `make doctor-help` and
`make audit-help` wrap the common invocations.

One cross-cutting consequence: `rulegen` persists through `RetainRateSource`, which rewrites
source rows **wholesale**. Anything runtime-valued that has been added to `rate_sources` is
destroyed by an unrelated `rulegen` run — see `beacon-storage`.
33 changes: 29 additions & 4 deletions .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, 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.
description: Beacon's HTTP surface and browser client — endpoint contracts not obvious from the router code (chart period whitelist, the forced alert rows and their 409, the multi-week days array on /weather/current), Telegram WebApp initData HMAC auth and its single middleware mount, where cmd/web binds and the --api-dsn flag, the PublicError contract and the three assertions every controller error test owes, content-hashed WASM asset URLs and nginx location ordering, and the Mini App's 2x2 navigation. Load before changing anything under internal/gateway, cmd/web, cmd/wasm, cmd/web/static or configs/nginx.*, internal/tools/tgwebapp/initdata.go, middleware.TelegramInitData or routes.MePrefix, internal.PublicError or internal/errors.go, dto.WeatherCurrentItem or WeatherForecastDayItem, or any /api/v1/me or /api/v1/public route.
---

# Beacon HTTP API and Mini App
Expand All @@ -13,9 +13,12 @@ are written down here.

- **Auth** — the `/api/v1/me/*` family is the only authenticated surface. The signed Telegram
WebApp `initData` is accepted **only** in the `X-Telegram-Init-Data` header, never via
query string (a signed payload in the URL leaks into access logs and `Referer`). The HMAC
algorithm is in CLAUDE.md's Key Patterns; implementation in
`internal/tools/tgwebapp/initdata.go`.
query string (a signed payload in the URL leaks into access logs and `Referer`).

**The HMAC scheme.** The signature is HMAC-SHA256 under
`secret_key = HMAC_SHA256("WebAppData", botToken)` — the string literal is the *key* and
the bot token is the *message*, which is the way round that is easy to get backwards.
Implementation lives in `internal/tools/tgwebapp/initdata.go`.

**How it is enforced.** `NewRouter` registers the family on a private `ServeMux` and mounts
it once at `routes.MePrefix` behind `middleware.TelegramInitData`. A route is authenticated
Expand Down Expand Up @@ -69,6 +72,28 @@ 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 public HTTPS origin is a CLI flag, not an env var.** `--api-dsn` (format
`https://<host>/`, parsed by `dsninjector.Parse`) is hardcoded in the systemd unit's
`ExecStart` line and never in `.env`, so it travels with the deployment rather than with the
secrets file. `cmd/web/main.go` reads `BEACON_SQLITEDB_DSN` and `BEACON_TELEGRAMBOT_DSN` via
`dsninjector.Unmarshal(envName)` at startup, from the systemd `EnvironmentFile`; all three
configs must be present or the binary calls `log.Fatalf`.

## Error rendering: what an end user may see

`internal.PublicError` (in `internal/errors.go`, alongside `TraceError`, `StackTraceError`,
`HttpCodeError`, and the `ErrNotFound` sentinel) carries messages **safe to show to end
users**. Wrap at the point the error is created — the service layer — with
`internal.NewPublicError("...")` when the failure meaningfully tells the user something;
return a plain `error` for everything else (DB down, unexpected nil, ...). The controller
catches every sub-handler error and sends `PublicError.Details()` for a public error, else a
generic fallback constant.

Every controller test on an error branch must assert three things: (1) a response was
actually sent, so the user is not left in silence; (2) its text equals `PublicError.Details()`
for a public error; (3) its text equals the fallback constant for a plain error. The first is
the one that catches a handler returning early without writing anything at all.

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

`GET /api/v1/me/weather/current` carries a `days` array per city: one entry per city-local
Expand Down
57 changes: 56 additions & 1 deletion .claude/skills/beacon-storage/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,64 @@
---
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, 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.
description: Beacon's SQLite storage rules — connection PRAGMAs, the BEGIN IMMEDIATE write / deferred read split, the repository pattern, hot/archive tiering of rate_values and execution_history, source-deletion cascades, the migrator contract and immutable migration filenames, columns that look droppable but are not, historical migration tests, and reading production data from snapshots. Load before writing or reviewing any query in internal/repository or internal/infrastructure/sqlitedb, adding or altering a migration under ./migrations, or touching collection.MaintenanceAgent, sqlitedb.Migrator or RequireMigratedSchema, NewSQLiteClientEx, Transaction/ReadOnlyTransaction, a DSN _pragma= or _txlock setting, RetainRateSource or RemoveRateSource, rate_source_health, weather_forecast_days, or stubSQLiteDBThrough.
---

# Beacon storage

Read this before any repository query, any migration, or any attempt to look at
production data.

## Connection, PRAGMAs and transactions

Engine: SQLite, accessed via the pure-Go `modernc.org/sqlite` driver (no CGO). Three PRAGMAs
are applied on connection open, from two different places for two different reasons:

- `foreign_keys=ON` and `busy_timeout=5000` are passed as `?_pragma=` query parameters on the
DSN (see `connectionOptions` in `config.go`). The `modernc.org/sqlite` driver re-applies
them in its `Open` hook on every new connection the `database/sql` pool opens, which is the
only way to keep these per-connection settings consistent across `SetMaxOpenConns(N>1)`.
- `journal_mode=WAL` is persisted in the database file header, so it is set once via `db.Exec`
inside `NewSQLiteClientEx`.

`busy_timeout` (5 s) is the driver-level retry window for lock contention; it must stay
strictly less than the Go-level `Timeout` so the context deadline always fires *after* the
driver retry expires.

**Writes open `BEGIN IMMEDIATE`; reads stay deferred.** The DSN also carries
`_txlock=immediate`, and the driver applies that begin mode only when
`sql.TxOptions.ReadOnly` is false — so `Transaction` takes the WAL write lock at `BEGIN`
while `ReadOnlyTransaction` keeps a plain deferred `BEGIN` and still runs concurrently with a
writer.

That split is what makes `busy_timeout` reachable at all. A deferred transaction begins as a
reader and *promotes* at its first write, and SQLite refuses to invoke the busy handler on a
promotion — two connections both waiting to promote would deadlock — so it returns
`SQLITE_BUSY` on the spot. Collector/notifier/web contention therefore lost writes in
milliseconds while a 5 s retry window sat unused (12 rate values and 5 `execution_history`
rows in one production log). Taking the lock at `BEGIN` is not a promotion, so the wait is
real.

Consequences for new code:

- **`Transaction` is for paths that write.** Reads go through `ReadOnlyTransaction` or they
serialise against each other for nothing. `SQLiteClient.Rollback` — and `Ping`, and through
it the `/health/check` inspector — is read-only for that reason: a readiness probe queued
behind a collector tick would report a busy database as a dead one.
- **Two write transactions cannot be open at once** in one process; the second `BEGIN` waits
for the first. Open, write and commit inside one function, as every repository method does.

**The repository pattern.** Each repository type owns its own SQL, migration, and query
helper functions, and runs them inside explicit transactions — `r.db.Transaction(ctx)` to
write, `r.db.ReadOnlyTransaction(ctx)` to read. Repositories are passed as interfaces into
the service and handler layers.

### Deleting a source cascades into its history

Foreign keys point from `rate_values`, `rate_user_subscriptions` and `rate_user_events` to
`rate_sources(name)` with `ON DELETE CASCADE`, so deleting a source destroys every dependent
row. Read the warning on `RemoveRateSource` before wiring it to any endpoint. The archive
tier is deliberately exempt — see `rate_values_archive` under the tiering rules below.

## Hot / archive tiering

The two append-only telemetry tables are each split into a bounded **hot** working set and
Expand Down Expand Up @@ -75,6 +126,10 @@ missing or empty `__schema_migrations` table is fatal:
log.Fatalf("schema not initialised: run cmd/migrator before starting the service")
```

Schema reconciliation is therefore **deploy-time, not startup-time**: `configs/beacon.service`
deliberately carries no `ExecStartPre` migrator, and the `beacon-migrate` one-shot unit runs
as root after the release symlink flips, so the CI deploy user never writes the database.

Migration files live at `./migrations/*.sql`. Filename convention:
`<YYYYMM>.<NNN>.<table>.<description>.sql` (e.g.
`202605.001.rate_sources.table_initiate.sql`). The `<NNN>` segment is a **global**
Expand Down
Loading