Skip to content

Rewrite C++ CGI to Python package with PHP web layer - #1

Merged
mousebrains merged 60 commits into
mainfrom
update_claude
Mar 2, 2026
Merged

Rewrite C++ CGI to Python package with PHP web layer#1
mousebrains merged 60 commits into
mainfrom
update_claude

Conversation

@mousebrains

Copy link
Copy Markdown
Owner

Summary

Complete rewrite of the kayak river levels system from C++ CGI binaries to a Python package (kayak) with a PHP web layer:

  • Python pipeline: fetch → calc-rating → merge → calculator → build, run hourly via systemd timer
  • 8 parsers: USGS, USBR, NWPS, NWRFC XML, USACE CDA, USACE Outflow, WA.gov (replacing 15+ C++ parsers)
  • Static HTML generation: self-contained pages with inlined CSS and SVG sparklines, optimized for 3G/mobile
  • PHP dynamic pages: description, plots, API, picker, edit — sharing the same SQLite database
  • SQLAlchemy 2.x ORM: 18-table normalized schema with Alembic migrations
  • 293 tests with in-memory SQLite, plus ruff linting and mypy type checking
  • Deployment: nginx config, systemd timers (pipeline + decimate + backup), VPS setup guide for Hetzner and Oracle Cloud Free Tier
  • Auto-healing: parsers auto-create missing Source records for multi-station URLs (USBR)
  • Concurrent fetching: asyncio + aiohttp with per-host semaphores
  • Legacy removal: C++ source, old Makefiles, web templates, and gen.sql scripts removed

Test plan

  • pytest — 293 tests pass
  • ruff check src/ tests/ — clean
  • levels pipeline — full pipeline completes without errors
  • levels build — generates HTML/CSV/text to public_html/
  • Verify PHP pages load: description, plot, API, picker

🤖 Generated with Claude Code

mousebrains and others added 30 commits February 26, 2026 11:24
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Translates the entire C++ water-level data aggregation system to Python
using Flask, SQLAlchemy, Matplotlib, and Click. Consolidates 3 MySQL
databases into a single schema with a unified measurements table.

- Foundation: pyproject.toml, SQLAlchemy models (14 ORM classes), config,
  engine factory, CLI entry point, database init with seed data
- 19 data parsers (USGS, NOAA, NWRFC, USBR, USACE, CBRFC, IDWR, etc.)
  with decorator-based registry replacing makeParser() if/else chain
- Pipeline commands: fetch, merge, calc-rating, calculator, build, pipeline
- Flask web app with 6 route blueprints, legacy CGI URL compatibility,
  Matplotlib time-series plotting, JSON data API
- Alembic migration setup for production MySQL
- 22 passing tests against in-memory SQLite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the flat Master/MergedMaster/Correction schema with the production
16-table normalized schema (Gauge, Source, GaugeSource, FetchUrl, Section,
Observation, LatestObservation, Rating, etc.). Move URL/parser definitions,
builder columns, and description fields from DB tables to YAML config files
(data/sources.yaml, data/builder.yaml, data/descriptions.yaml). Update all
parsers, CLI commands, web routes, and tests for source_id-based queries
and lowercase DataType enum values (flow/gauge/inflow/temperature).

All 33 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename the CLI entry point from 'kayak' to 'levels' to match the
project's domain. Add scripts/import_from_dump.py which parses a
mysqldump of levels_todo and populates the local SQLite database
with the normalized schema (states, gauges, sources, sections,
~12M observations, latest_observation cache).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allows testing parsers against previously saved pages from --output-dir
without hitting the network. Extracts content retrieval into _get_content()
helper shared by both the main loop and _fetch_single.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Group URLs under their parser name as the top-level key, dropping the
redundant per-entry parser and group fields. Rename usgs.rdb to usgs to
match the registry. Mark cdec, cdec.station, and crohms as enabled: false
since they have no Python parser implementations yet.

Update load_sources() to flatten the new structure back to the same
list-of-dicts interface so no downstream callers change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Delete parser implementations that have no associated data source URLs:
cbrfc, idahoPower, idwr, noaa, noaa.xml, noaa2, nwrfc, ocs,
ocean.newport, usace.ca, usace.resv, usbr.pn, usbr.special.

Update registry.py to only import the 5 active parsers: usgs, nwrfc.xml,
usbr, usace.outflow, wa.gov.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Create src/kayak/cli/logger.py with add_logging_options() decorator and
setup_logging() mirroring TPWUtils/Logger.py: RotatingFileHandler,
optional SMTPHandler, mutually exclusive --debug/--verbose flags.

Wire logging into the CLI group in main.py so all subcommands inherit it.
Remove duplicated --verbose options and logging.basicConfig() calls from
fetch, build, calc_rating, calculator, merge, and pipeline. Convert
verbose-gated click.echo() calls to logger.debug()/logger.info(). Remove
verbose parameter from BaseParser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The entire C++ codebase (168 .C/.H files), Makefiles, scripts/master,
files/, and gen.sql/ have been superseded by the Python translation.
Retain scripts/import_from_dump.py which is still used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace Click decorators with argparse add_subparsers pattern matching
the TPWUtils addArgs/mkLogger convention. Each subcommand module now
exposes addArgs(subparsers) and a run function. Pipeline calls
subcommand functions directly instead of ctx.invoke(). Remove click
from dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The calc expressions from the production dump use a 3-part format
(key::source_name::type) but the parser assumed 2-part. Also add
gauge name resolution so merge target names like
Mohawk_Springfield_merge resolve through the Gauge→GaugeSource→Source
chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract fetch options into addArgs_options() so both the fetch
subcommand and pipeline subcommand can reuse them. This allows
running the full pipeline with fetch-specific flags like
`levels pipeline -i -I /tmp/fetched-pages`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…to 76%

- Remove C++ artifacts (.orig files, mkFlows binary, tpw graphics) and empty dirs
- Extract get_primary_source_id/get_source_ids_for_gauge helpers into info_db.py
  and update 7 callers across CLI and web routes
- Fix == False to .is_(False), remove unused imports, move lazy import re to
  module level, fix deprecated datetime.utcnow() calls
- Normalize error handling: replace print(stderr) with logger.error() in all CLI
- Configure ruff (E/W/F/I/UP/B/SIM/RUF) and mypy in pyproject.toml
- Apply ruff auto-fixes: StrEnum migration, UP037/UP045 cleanups, zip(strict=True)
- Fix all mypy errors (type annotations, Any returns, import-untyped)
- Add 200 new tests (233 total): utils, parsers, DB, CLI, web routes, plotting
- Add pytest markers, coverage config, and dev deps (ruff, mypy)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…elivery

The Flask/matplotlib stack required Passenger/WSGI on DreamHost and added
unnecessary round trips.  This replaces it with static HTML files built
to disk by `levels build` plus lightweight PHP scripts for dynamic routes,
all served directly by Apache.

Key changes:
- build.py writes self-contained HTML to public_html/ with inlined CSS,
  responsive mobile-first card layout, inline SVG sparklines, and direct
  links (no more ?D=/?f= redirects)
- PHP scripts handle dynamic content (description, plot, api, latest,
  view, edit) with shared includes for DB, header/footer, LTTB, SVG plots
- Lightweight SVG plot generation replaces matplotlib (3-8KB vs 20-100KB)
- LTTB downsampling in both Python (sparklines) and PHP (plots, API)
- PWA support: service worker (network-first/cache-fallback), manifest,
  single 192px icon replaces 467KB of apple-touch-icon variants
- Apache .htaccess with mod_deflate, cache headers, error pages
- Remove flask, Jinja2, matplotlib, httpx dependencies
- Remove web.templates/ (30 legacy CGI/template files)
- All 208 tests pass, zero ruff violations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New picker.php lets users select states, search/filter sections, and build
a custom levels page from checked selections. Custom page URL (?ids=1,2,3)
is bookmarkable and shareable with no server-side persistence needed.

- php/picker.php: state pill filters, AJAX section endpoint, text search,
  section checklist with sticky action bar
- php/custom.php: renders standard levels table for arbitrary section IDs,
  same HTML structure/classes as static state pages
- header.php: add Picker nav link with active state support
- style.css: picker-specific styles (pills, search, table, action bar)
- public_html symlinks for both new pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
MySQL uses GROUP_CONCAT(name SEPARATOR ', ') while SQLite uses
GROUP_CONCAT(name, ', '). Detect the PDO driver to pick the right syntax.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
description.php: add Google Maps link, current readings table with
status indicators, inline 60-day SVG plots for flow/gage/temp. Removes
the old plain-text data links row superseded by the richer content.

build.py: split index.html into a lightweight state-card grid landing
page and move the full all-states table to all.html. Update nav links
from /index.html to /all.html for the "All" view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add running_median() to smooth sparkline data with 3h sliding window
- Change merge_sources() to use median for same-timestamp conflicts
- Color flow/gage values by section level (low/okay/high from section_level)
- Append red "(est)" to names where source uses calc_expression
- Gray/italic rows with stale data (>48h); hide expired rows (>7d)
- Add CSS classes for level-low, level-okay, level-high, stale, est
- Add info_db helpers: is_source_calculated(), classify_level()
- Add tests for running_median and median merge behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The observed_at from the database is timezone-naive; normalize it
to UTC before comparing with datetime.now(UTC).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Render all date columns and "Updated" timestamps as <time datetime>
elements with ISO 8601 UTC values. A small inline JS snippet converts
them to the browser's local timezone on page load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Gauge values: 1 decimal place (was 2) in HTML table
- Temperature values: 1 decimal place (was integer) in HTML table
- Description page: format flow as integer, gauge/temp as 1 decimal
- Description page: add Data Sources section with links to fetch URLs
  and calculated expressions for all sources linked to the gauge
- Description page: render timestamps as <time> for local timezone
- Footer: add timezone conversion JS so all PHP pages get local times

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Show gauge location, put-in, and take-out coordinates in decimal
degrees when available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Change "Washington Kayak Club" to "Willamette Kayak and Canoe Club"
- In Data Sources, parse calc expression gauge references
  (e.g. nP::S_Santiam_Cascadia_merge::flow) and link them to the
  detail page of the section that uses that gauge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Skip sections with no flow/gauge/temperature data from the main
  levels table so only sections with active readings are shown
- Add a collapsible "All Reaches" directory below the table listing
  every section alphabetically with links to detail pages
- Responsive multi-column layout (2/3/4 columns by screen width)
- Fix test mock to include flow data for the filtered table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reads per-station tables (flow_*, gage_*, temperature_*) from the
legacy levels_data MySQL database and upserts into the unified
observation table. Supports both SQLite and MySQL targets, with
options for time range filtering, dry run, and batch size control.
Also syncs the Latest table into latest_observation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The project has been rewritten from C++ CGI to a Python package with
a PHP web layer. Update CLAUDE.md with current build commands, test
and lint instructions, pipeline architecture, database schema, parser
system, and development conventions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reads from levels_todo (metadata), levels_data (observations),
and levels_page (page cache) and writes to wkcclevels with the
new normalized schema (snake_case columns, string enums).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request May 23, 2026
Adds a Responses section to docs/REVIEW_gradient_profile.md mapping each
finding to the action taken in commit 1ea9a4b plus the verification
results. Findings #1, #4, #6 fixed; #3 reworded as readability-only (the
"bug" was already correctly handled by the 1m close-enough tolerance,
verified by identical sample counts on re-resample); #2/#5/#8/#9
deferred with rationale; #7/#10 confirmed clean. Verification checks #1
and #2 both pass after fixing a stale 0045 docstring and dropping the
wall-clock timestamp from the 0046 generator (now byte-stable across
regens).

Format: one entry per finding, so the next live-host review pass can
read top-to-bottom and see exactly what changed vs. what was tabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request May 31, 2026
The PR #99 external verification pass re-confirmed every finding,
severity, and the B+ grade against db34ae0 (recommendation: merge), and
flagged one inaccurate evidence line plus three off-by-one citations.
Corrected:

  - MED #1: drop the `git branch --contains` "reachable only from main"
    claim — feature branches later cut from main now contain 9b428bb /
    6007c21, so containment no longer distinguishes them. The direct-to-
    main conclusion stands on the durable evidence (linear f3ed673..HEAD,
    no merge commit, missing (#NN) suffix).
  - citations: ci.yml:114→115, SourceUrlTest.php:83-84→84-85,
    check_reaches.py:212→213.

Added an External-review note recording the pass + the one below-LOW item
it surfaced (the 0069/0070 header comments' now-stale PENDING_RECONCILIATION
wording).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request May 31, 2026
* docs: round-6 deep project review (graded B+, ▲ from B−)

Sixth deep project review of the entire tracked repo — 6 cold facet
auditors (Python, PHP/security, schema/data, tests/CI, ops, docs) +
synthesizer hand-re-verification, judging two bands: (A) did round-5's
fixes durably stick, and (B) what did #93#98 + migrations 0069–0071 +
the two direct-to-main commits introduce.

The recursive integrity check passes cleanly for the first time in the
series: every round-5 fix (R1.1/R1.2/R1.3/R1.5/R2.1/R3.x/R4.x) landed as
a committed PR and is still present at HEAD, and every mechanized guard
is proven non-vacuous by break-it experiment. New code is clean — no
CRIT/HIGH: #93 USACE kcfs→cfs (correct, per-series), migrations
0069/0070/0071 (idempotent, FK-clean, Bridgeport DROP cascade
residue-free), #96/#97 multi-state pickers, #95/#98 gradient JS.

Two MED findings, both recurrences of round-5 classes closed by
documentation not mechanization: (1) two direct-to-main commits, one of
which broke CI on main (the {}-is-a-dict bug); (2) a nightly snapshot
overrode migration 0067's sort_name for gauge 217 with no migration.
Root cause is shared — main accepts un-CI-gated direct pushes from both
humans and the snapshot bot. Lever: route everything through a CI gate
(branch protection + a self-gating/auto-merging snapshot), a
snapshot-column drift guard, and teach seed_gauge_display to preserve
migration-pinned sort_names.

Two facet over-claims dissolved on hand-re-verification (the USACE
temperature-docstring drop is a correct fix; check_reaches DOES
range-check vertices via validate_lat_lon).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fold external-review corrections into the round-6 review (#99)

The PR #99 external verification pass re-confirmed every finding,
severity, and the B+ grade against db34ae0 (recommendation: merge), and
flagged one inaccurate evidence line plus three off-by-one citations.
Corrected:

  - MED #1: drop the `git branch --contains` "reachable only from main"
    claim — feature branches later cut from main now contain 9b428bb /
    6007c21, so containment no longer distinguishes them. The direct-to-
    main conclusion stands on the durable evidence (linear f3ed673..HEAD,
    no merge commit, missing (#NN) suffix).
  - citations: ci.yml:114→115, SourceUrlTest.php:83-84→84-85,
    check_reaches.py:212→213.

Added an External-review note recording the pass + the one below-LOW item
it surfaced (the 0069/0070 header comments' now-stale PENDING_RECONCILIATION
wording).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request Jun 2, 2026
Closes residual #1 from the #116 review. The OSM read window was the endpoint
bbox, padded by endpoint separation -- which can't cover a reach whose endpoints
are close but whose channel bows far laterally (a tight oxbow): `sep` is small,
the pad stays at the floor, and the bow clips out of the window -> OSM no-paths
-> NHD fallback.

`trace_reach` already computes the NHD trace, whose bbox bounds the actual
channel for *any* shape. Read OSM within `_coords_bbox(nhd_coords, 0.02deg)`
when an NHD trace exists; keep the (adaptive) endpoint `_bbox` only as the
fallback when there's no NHD trace. McKenzie still resolves to OSM end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request Jun 2, 2026
* fix(tracing): address #114 review follow-ups (OSM trace mode)

Four non-blocking items from the #114 review; none change the safety invariant
(OSM is still gated against NHD, or falls back to it):

1. `_bbox` pad now scales with the endpoint separation (0.05 deg floor) so a
   reach that bows laterally outside the put-in/take-out box isn't clipped out
   of the OSM read window (which silently no-paths OSM -> NHD fallback). Short
   reaches are unchanged -- the floor dominates until ~0.2 deg separation.
2. The NHD-unavailable branch now returns source "osm (ungated)" and logs a
   WARNING. That's the one path emitting an un-cross-checked OSM geometry; it's
   now identifiable in the CLI output instead of a bare "osm".
3. `_walk` docstring clarifies the traversable graph spans ALL segments; `pool`
   only restricts which segments the endpoints snap to.
4. CLI validates `--putin`/`--takeout` are LAT,LON (clean exit 2) rather than a
   raw IndexError traceback on a missing comma.

Adds a `_bbox` adaptive-pad test. pytest 1039 passed; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tracing): bound the OSM read window by the NHD-trace bbox

Closes residual #1 from the #116 review. The OSM read window was the endpoint
bbox, padded by endpoint separation -- which can't cover a reach whose endpoints
are close but whose channel bows far laterally (a tight oxbow): `sep` is small,
the pad stays at the floor, and the bow clips out of the window -> OSM no-paths
-> NHD fallback.

`trace_reach` already computes the NHD trace, whose bbox bounds the actual
channel for *any* shape. Read OSM within `_coords_bbox(nhd_coords, 0.02deg)`
when an NHD trace exists; keep the (adaptive) endpoint `_bbox` only as the
fallback when there's no NHD trace. McKenzie still resolves to OSM end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request Jun 4, 2026
install-config.sh (MEDIUM): the secrets sanity check enforced only a
non-empty TURNSTILE_SECRET via a bare grep, so a fresh host could pass
the installer with the site key missing — and turnstile_enabled()
requires BOTH JSON keys, recreating the captcha-silently-off failure
this PR closes. The guard now requires both keys and parses with the
SAME semantics as the install wrapper's merge (export prefix, quote
strip, empty = disabled — the old grep also rejected export-style
lines and accepted quoted-empty values). A no-root `--check-secrets
[FILE]` mode makes the real script testable; 7 new tests include a
guard↔wrapper parity case pinning that any file the guard accepts
merges both keys.

review_send_reply (minor #1): the reviewer-note merge happened in PHP
from the request-start row, so two concurrent reply tabs (both passing
the `pending` predicate — replies don't flip status) would
last-writer-win and drop the first reply's note. The append now happens
SQL-side inside the atomic UPDATE; reviewer_note_entry() extracted so
the stamp format stays shared with merge_reviewer_note(), whose
docstring now explains why terminal actions can keep the PHP-side
merge. Regression test drives the stale-row shape and asserts both
notes survive.

validate-config (minor #2): add the SQLITE_ prefix and allowlist
SQLITE_PATH (PHP db.php fallback + health-check.sh DB override; not a
model field — python uses DATABASE_URL). Strict scan re-verified rc=0
against the live host's env set with the new prefix active.

health-check.sh (minor #3): comment the bare fu.is_active under
GROUP BY s.id — SQLite's bare-column extension, safe because is_active
is functionally dependent on s.id; flags it against a future
"fix" to MAX() or an unexamined port to a stricter engine.

Findings #4 (wrapper merges all secrets.env keys — observation, no
action; documented in secrets.env.example) and #5 (deploy-day
transient — acknowledged in the scope notes) per the review need no
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request Jun 4, 2026
…crets merge, runbook fixes (#119)

* fix: address 2026-06-03 gpt-5.5 project-review findings

health-check.sh (HIGH): the script only checked the single global
MAX(observed_at), so a dead feed — or a source that never produced
data at all — was invisible while any other source kept writing, and
the healthchecks.io heartbeat stayed green. Add a per-source liveness
check: active gauge-linked fetch-backed sources fail when never-fed
or silent > STALE_SOURCE_DAYS (default 14, env-overridable,
numeric-guarded against silently disabling the SQL); OGC-fetched USGS
sources (selected by agency + gauge link — `fu.is_active IS NOT 1`
so an inactive-fetch_url USGS source isn't a scope hole) get the same
silent->fail window once fed, but never-fed ones are exempt as
speculative metadata additions awaiting upstream OGC coverage
(operator decision 2026-06-03). Reconcile docs/slo.md SLO F to what
is actually enforced — the documented per-source "cadence + 2 h"
model never existed anywhere. 12 regression tests run the real script
against an ORM-built SQLite DB (tests/test_scripts/test_health_check.py).
Verified against the prod snapshot: 0 offenders at the 14-day window,
so the new check is green on deploy day.

config-refresh runbook (MED): docs/operations.md still told the
operator to run the old `sudo levels emit-config --out ...` — the
exact pat->root RCE the review-3 R1.5 wrapper eliminated. Rewrite to
the real flow (`emit-config --dry-run` piped into the root-owned
kayak-install-runtime-config) and document the one-time wrapper +
sudoers install. Also fix deploy/SETUP.md's claim that the §7 sudoers
grant invokes the pat-writable venv levels binary.

local PHP quick start (MED): README, CLAUDE.md, and .env.example all
ended with a bare `php -S`, which 500s every dynamic page since
Config became fatal-on-missing (T3.3 Phase 4). Add the
`levels emit-config --out` + KAYAK_CONFIG_PATH step everywhere and
correct the stale "PHP gets SQLITE_PATH from nginx fastcgi_param"
claims (database_path from the runtime-config JSON first,
SQLITE_PATH env fallback). Smoke-tested locally: 200 with the config
step, 500 without.

review reply race (LOW): review_send_reply() updated reviewer_note
with no status predicate, so a stale "reply, keep pending" tab could
mutate an already-approved/rejected row and email the editor a
misleading "still pending" message. Use the same atomic
`WHERE id = ? AND status = 'pending'` + rowCount() pattern as the
terminal actions, return bool, and surface "Already reviewed by
another maintainer." in the handler. Race regression test added.

validate-config (LOW): add METADATA_ and USGS_ to the scanned
prefixes, declare hc_fetch_osmb/hc_status (referenced by their
systemd units but never added as fields — either would have failed
the first strict deploy), and allowlist KAYAK_DATA / KAYAK_VENV /
USGS_API_KEY (env-read names that must not become model fields;
USGS_API_KEY in particular would otherwise be emitted into the
www-data-readable runtime-config.json). Flip scripts/deploy.sh to
`validate-config --known-env --strict`; verified rc=0 against the
live host's actual .env contents.

stale docs (LOW): drop the long-dead `pages` table from db_sync.md
and export_metadata.py's docstring, and correct the
levels-test.wkcc.org/_internal/ claims — that host 301s wholesale to
levels.wkcc.org since 2026-05-19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: merge secrets.env into runtime-config JSON in the install wrapper

gpt-5.5 take-2 review (2026-06-03), MEDIUM, confirmed FIRED in
production: the live login page serves no Turnstile widget — captcha
is silently off. Root cause is the collision of two individually-sound
changes: review-3 R1.5 moved the runtime-config render to unprivileged
pat (`levels emit-config --dry-run`), which cannot read
/etc/kayak/secrets.env (0600 root:www-data), so TURNSTILE_SITE_KEY /
TURNSTILE_SECRET vanish from the JSON; and T3.3 Phase 4 removed PHP's
getenv fallback, so the FPM-pool env re-export no longer rescues it.
turnstile_enabled() then false-paths to turnstile_verify() === true.
config.py's secrets.env comment still described the pre-R1.5
root-rendered flow that made this work.

Fix: the root-owned kayak-install-runtime-config wrapper now merges
secrets.env into the piped JSON before installing — each KEY=VALUE
lands as lowercase(KEY) unless the rendered JSON already carries a
non-empty value (parity with config.py's load_dotenv override=False
precedence). This keeps both prior security decisions intact: the
R1.5 boundary (no pat-writable code runs as root; the merge code is
inside the fixed root-owned wrapper) and Phase 4's JSON
single-source-of-truth. `export KEY=VALUE` lines are accepted, matching
python-dotenv and systemd EnvironmentFile parsing of the same file
(adversarial-review finding: without that strip an export-style
secrets.env would mint a bogus "export turnstile_secret" key and
re-create the captcha-off bug).

Test hooks KAYAK_INSTALL_DEST / KAYAK_INSTALL_SECRETS are honored only
when euid != 0 — as root (the sudoers entry) the paths stay fixed, and
sudo's env_reset (no SETENV tag) strips the vars anyway, so the hooks
add no escalation surface. 10 new wrapper tests run the real script
(tests/test_scripts/test_install_runtime_config.py); a new PHP test
drives the full production shape end-to-end (pat-shaped JSON without
turnstile keys + secrets.env -> wrapper -> Config -> turnstile_enabled()
true).

Prose swept to match: config.py secrets comment, turnstile.php
docblock (claimed a getenv fallback Phase 4 removed),
kayak-fpm-pool.conf (its env re-export is now an unread legacy
channel), secrets.env.example (documents the merge flow + both keys),
SETUP.md, operations.md, deploy.sh step 3.5. Also annotated the
Phase 4.3 draft SLO targets in PLAN_production_discipline.md as
superseded by docs/slo.md (take-2 residual note).

Deploy note: the wrapper at /usr/local/sbin is a copied artifact — the
fix is live only after `sudo install -m 0755 -o root -g root
deploy/kayak-install-runtime-config.sh
/usr/local/sbin/kayak-install-runtime-config` and a deploy (or the
emit|install step) re-renders the JSON. Until then
kayak-config-drift.service flags the stale copy (byte-exact cmp).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: archive the gpt-5.5 project-review reports (2026-06-03)

Both rounds of the external review whose findings the two preceding
commits address, archived per the docs/done/REVIEW_* convention.
Round 1: healthcheck per-source freshness (HIGH) + runbook/quick-start
staleness + review-reply race + validate-config gaps. Round 2 (take 2,
reviewing commit 39e7b6c): the Turnstile secrets-merge hole in the
runtime-config install pipeline, confirmed fired in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: index the archived gpt-5.5 reviews in docs/done/README.md

test_docs_done_plans_are_indexed (review-4 R2.3 guard) requires every
docs/done/REVIEW_*.md to have an index row — fc8c16d archived the two
reports without one, which is exactly the drift the guard exists to
catch. CI red on #119; lesson re-learned: docs-only commits still get
the full test gate in this repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address PR #119 review findings

install-config.sh (MEDIUM): the secrets sanity check enforced only a
non-empty TURNSTILE_SECRET via a bare grep, so a fresh host could pass
the installer with the site key missing — and turnstile_enabled()
requires BOTH JSON keys, recreating the captcha-silently-off failure
this PR closes. The guard now requires both keys and parses with the
SAME semantics as the install wrapper's merge (export prefix, quote
strip, empty = disabled — the old grep also rejected export-style
lines and accepted quoted-empty values). A no-root `--check-secrets
[FILE]` mode makes the real script testable; 7 new tests include a
guard↔wrapper parity case pinning that any file the guard accepts
merges both keys.

review_send_reply (minor #1): the reviewer-note merge happened in PHP
from the request-start row, so two concurrent reply tabs (both passing
the `pending` predicate — replies don't flip status) would
last-writer-win and drop the first reply's note. The append now happens
SQL-side inside the atomic UPDATE; reviewer_note_entry() extracted so
the stamp format stays shared with merge_reviewer_note(), whose
docstring now explains why terminal actions can keep the PHP-side
merge. Regression test drives the stale-row shape and asserts both
notes survive.

validate-config (minor #2): add the SQLITE_ prefix and allowlist
SQLITE_PATH (PHP db.php fallback + health-check.sh DB override; not a
model field — python uses DATABASE_URL). Strict scan re-verified rc=0
against the live host's env set with the new prefix active.

health-check.sh (minor #3): comment the bare fu.is_active under
GROUP BY s.id — SQLite's bare-column extension, safe because is_active
is functionally dependent on s.id; flags it against a future
"fix" to MAX() or an unexamined port to a stricter engine.

Findings #4 (wrapper merges all secrets.env keys — observation, no
action; documented in secrets.env.example) and #5 (deploy-day
transient — acknowledged in the scope notes) per the review need no
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request Jun 14, 2026
…lure (errtrace)

Adversarial review of #192 surfaced a chain of rollback gaps. Fixing finding #1
(arm DOCROOT_BUILT before the build) exposed a deeper, pre-existing PR #190 bug:
the rollback never ran for the activation mutation steps at all.

- **errtrace (the big one).** The deployer ran `set -euo pipefail` without `-E`.
  Every DB/build mutation runs through `run_app()` (a function), and without
  errtrace a failure INSIDE a function aborts under `set -e` WITHOUT triggering
  the `ERR` trap. So a failed `run_app … migrate/sync/import/build` exited with
  the DB already mutated, consumers stopped, maintenance still on — and NO
  rollback (only the EXIT scratch-cleanup). `set -Eeuo pipefail` makes those
  failures hit `trap rollback ERR`. The trap is armed for the activation phase
  only (staging has no ERR trap, so `-E` is inert there); atomic_relink's `mv`
  probes sit in `if` conditions, which stay exempt. Surfaced writing finding #1's
  test — the docroot-rebuild fix is moot if rollback doesn't run.

- **#1: DOCROOT_BUILT armed before the build, not after.** `build` writes the
  live docroot in place (stage → per-file rename → orphan sweep), so a failure
  mid-write leaves it mixed yet exits non-zero. The flag must mean "build
  started" (docroot may have changed) so rollback rebuilds; a rebuild when it was
  in fact untouched is harmless/idempotent.

- **#3: normalize an absolute `current` symlink target.** The rebuild used
  `$ROOT/$PREV_TARGET`; a manual-recovery absolute `current` made that
  `/opt/kayak//opt/...`. Compute `PREV_DIR` with an absolute/relative case split.

- **nit #4: keep the rollback rebuild's stderr** (to `$SCRATCH/docroot-rebuild.err`,
  retained) instead of discarding it — the recovery path is where the reason
  matters most.

- **#2 (ACL): commit the shared-docroot web ACL** to deploy/SETUP.md's cutover
  section — the recursive + default `u:www-data:rX` on `/var/cache/kayak/docroot`
  the deployer doesn't grant; without the `-d` default ACL each freshly built
  page 403s. The nginx-root / FPM-open_basedir / unit verification gate stays a
  4C-renderer deliverable (it owns those templates).

Tests: two new slow tests — build mutates the docroot then exits 1 → rollback
rebuilds (proves the flag-before-build + errtrace fix); and an absolute `current`
target → rebuild reads the normalized dir. Full slow suite 5 passed, non-slow
1725 passed, ruff/format/shellcheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mousebrains added a commit that referenced this pull request Jun 15, 2026
…add tests

- #1 (the conscious trade-off): document the KNOWN LIMITATION inline — masking the
  whole line means config-drift no longer verifies the *content* of `root` /
  `open_basedir` between deploys (a manual tamper of just those lines wouldn't be
  flagged). Note that the deployer's serving-path gate still verifies them at
  deploy time, and record the verify-against-`render-serving` upgrade as the
  follow-up that closes the continuous-monitoring gap.
- #2: comment the FPM-pool-path duplication (RENDER_NORMALIZED + MANIFEST) so a
  PHP-version bump updates both, not just one.
- #4: add tests/test_scripts/test_config_drift.py — sources the script in a new
  lib mode (KAYAK_DRIFT_LIB=1, early-return before the /etc manifest walk) and
  drives normalize_rendered directly, locking the surgical mask: the docroot
  `root`/`open_basedir` are exempt, but the ACME `root /var/www/certbot;` stays
  byte-checked (a tampered ACME root still differs). Guards the certbot exclusion
  against a future sed edit.

#3 (the favicon/security.txt aliases re-introducing drift) is mooted by PR #199,
which makes those aliases docroot-relative (try_files) rather than hardcoding the
cache path — so they stay generic and need no mask.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant