2026 · ENERGY SALES INTEL

GridSignals

GridSignals monitors 172 US energy companies and turns their public-record activity into scored, sourced signal cards — each mapped to one of 16 Microsoft security products and one of 21 configured trigger types. Thirteen data sources feed a stdlib-only Python pipeline that classifies, resolves, and decay-scores signals without any paid feeds. A nine-page FastAPI + HTMX dashboard surfaces evidence-tiered incident cards, operator-tunable scoring weights, and a budget-capped Claude Haiku accuracy judge. 337 commits · 115 PRs · 9 active days.

PUBLISHED
337COMMITS
115PULL REQUESTS
9BUILD DAYS
SOURCE: GITHUB COMMIT HISTORY · 2026-08-25
Build cadence — commits per active day
Aug 11 Heaviest day: 64 commits · Aug 16 Aug 21
GridSignals screenshot

Overview

GridSignals turns the public record around US energy companies into scored, sourced signal cards — each mapped to one of 16 Microsoft security products across 62 trigger-product pairs — and surfaces them through a nine-page FastAPI + HTMX web UI. The ingestion pipeline fetches from 13 classified or stored sources (SEC EDGAR submissions and full-text search, Federal Register, NERC enforcement dockets, CISA KEV, NVD CVE, ransomware.live, security-press RSS, EIA plant capacity, and more), resolves each item to one of 172 watchlist entities via deterministic CIK/ticker/LEI match with fuzzy fallback, classifies it into a trigger type, and applies a decay-adjusted score across five persisted components. The entire pipeline runs on Python stdlib — no third-party packages — and the only recurring external cost is a budget-capped Claude Haiku 4.5 accuracy judge ($0.016 measured on a 5-signal run; $0.50 ceiling). 337 commits, 115 PRs, 14 versioned migrations, 84 hermetic test modules, in 9 active days.

Project Design

The build runs config-as-data throughout: 16 products, 21 trigger types, 172 watchlist entities, scoring weights, and two combo rules all live as CSV seeds loaded into SQLite at startup, so the operator can tune the system through the Admin UI without a code deploy. A 1,225-line packaging test (`test_packaging.py`) enforces the stdlib-only constraint at CI time — among other things, it pins Streamlit's absence as a regression guard after the UI was rebuilt mid-build on FastAPI + HTMX + Tailwind. The combo engine needed a composable rule grammar; the stdlib-only constraint ruled out `eval`/`exec` and produced a hand-written `logic_expr` parser instead. Migrations are versioned and checksummed append-only; a mismatch on an already-applied file is a hard error, preventing silent schema drift across the 14-migration history. SIGTERM handling required a two-pass `/proc` walk to relay the signal to every cron-spawned job descendant, because tini's `-g` flag does not reach processes that cron places in their own sessions — a failure mode that only surfaced when stale lock files blocked the next startup.

Stdlib-only Python pipeline → SQLite → FastAPI + HTMX UI → Docker → Azure App Service
Ingestion
Python stdlib only (urllib, xml.etree, sqlite3) — no third-party packages13 source fetchers: SEC EDGAR, Federal Register, NERC, CISA KEV, NVD CVE, ransomware.live, security-press RSS, EIA plant capacity, GLEIF/Wikidata, USAspending, EPA ECHO, PHMSA, GDELT (stored, classifier unwired)Single-writer ingestion lock; idempotent native-ID/content-hash deduplication
Data
SQLite in WAL mode, FK enforcement, 5-second busy timeout14 versioned, checksummed append-only migrations (0001_initial through 0014_combo_scoring)CSV seeds: 16 products, 21 triggers, 62 trigger-product pairs, 172 watchlist entities, scoring weights, 2 combo rules
Processing
Entity resolver: deterministic CIK/ticker/LEI match → fuzzy fallback (0.90 auto / 0.75 review); 25-case adversarial test set9 classifiers with evidence-tier assignment and outreach gatingDecay scoring: base × 0.5^(age_days / half_life) × account_fit × scope_fit [× combo_multiplier]Hand-written logic_expr combo grammar parser (no eval/exec); 2 seeded rules
UI
FastAPI 0.136.1 + Uvicorn 0.46.0 + Jinja2 3.1.6 + HTMX (vendored)Tailwind CSS (standalone CLI, compiled to committed static file)9 pages; light/dark/system theme; keyset-paginated signal cards with stable permalinks
Deploy
Docker (python:3.12-slim base, tini as PID 1)Azure App Service via one-command PowerShell deploy script (deploy/azure-deploy.ps1)cron daemon for scheduled pipeline refresh; SIGTERM relay to cron-spawned descendants
Accuracy audit
Claude Haiku 4.5 judge over urllib — no Anthropic SDK$0.50/run budget ceiling; $0.016 measured on a 5-signal runGolden-set regression gate; versioned verdict storage per run

Key modules

Data

Entity resolver

Deterministic CIK/ticker/LEI/alias match with fuzzy fallback (0.90 auto-accept / 0.75 review queue); collision guard for bare-name ambiguity; 25-case adversarial fixture set covering shared names ('Dominion', 'Constellation'), subsidiary/DBA overlap, CIK zero-padding, ticker case, and fuzzy typos.

Pipeline

Ingestion runner

Shared fetcher framework enforcing source policy, TTL gating, idempotent native-ID/content-hash deduplication, single-writer lock, and per-source error containment — one source failing cannot abort the run for others.

Pipeline

Classifier suite

Nine classifiers (leadership, regulatory, incident, ransomware, company statement, security RSS, environmental enforcement, capital project, PHMSA enforcement) that assign an evidence tier and gate outreach scope per trigger type.

Pipeline

Scoring engine

Decay formula (score = base × 0.5^(age/half_life) × account_fit × scope_fit × combo_multiplier) with five persisted score components; operator-tunable weights via Admin UI; decay threshold flips a card's status to 'decayed'.

Pipeline

Combo engine

Hand-written logic_expr grammar parser — never eval/exec — composing trigger_any, obligation:any, and not_keyword clauses with AND. Two seeded combo rules; score-inert until a first account-tier signal fires against a watchlist entity.

Accuracy

Audit judge

Claude Haiku 4.5 accuracy judge called over urllib (no Anthropic SDK), budget-capped at $0.50 per run, with a golden-set regression gate and versioned verdict storage. Skips cleanly with exit 0 when no API key is present.

UI

FastAPI + HTMX UI

Nine-page multi-page app (Signal Feed, Explore, Digest, Account 360, Review Queue, Feedback/Precision, Recent Re-tiers, Regulatory Monitor, Admin/Config) with light/dark/system theme toggle, keyset-paginated signal cards, and stable per-card permalinks.

Key features

SIGTERM relay to cron-spawned descendants

Docker container stop looked clean at the process level but left a stale ingestion lock file on disk, blocking the next startup. The cause: tini's `-g` flag terminates every process in its own process group, but cron places each spawned job in a separate session, so they survive the signal. The fix is `relay_sigterm_to_cron_descendants()` in `deploy/entrypoint.sh` — a two-pass `/proc` walk that identifies the cron daemon's PID, collects every live descendant, and signals each one directly. The function is tested against a real three-level process tree in `test_packaging.py:623–727`, with the inline comment 'empirically confirmed against real Docker.'

SIGTERM arrives at tini (PID 1)entrypoint.sh SIGTERM trap fires → calls relay_sigterm_to_cron_descendants()Pass 1: locate cron daemon PID via /proc scanPass 2: walk /proc to collect all live descendants of cronSignal each descendant directlyIngestion lock file released; container exits cleanly
Without the relay: stale lock file remains on disk; next container startup blocks until lock timeout

Evidence-tiered incident cards

An energy company incident can surface simultaneously from an SEC filing, a ransomware tracker, and a security-press RSS item — each with a different evidentiary weight. GridSignals assigns each source a fixed tier wired into its classifier: confirmed (8-K Item 1.05 or a company statement), corroborated (The Record, which avoids leak-adjacent attacker claims), or unconfirmed early-warning (ransomware.live, BleepingComputer leak-adjacent content). Tier determines what the card may say and to whom: unconfirmed cards are operator-only, outreach is suppressed, and the card is labeled 'no company, regulator, or SEC confirmation.' The tier is code-wired in each classifier (`app/classify/incident.py`, `app/classify/ransomware.py`, `app/classify/security_rss.py`), not a runtime config that can drift.

Security & ops decisions

Evidence-tiered incident card pipeline — source to outreach gate
13 public-record sources (EDGAR, NERC, ransomware.live, security press, CISA KEV, …)
Ingestion: source policy check → idempotent deduplication → entity resolution to 172 watchlist entities
Classification: evidence tier wired per classifier — confirmed (8-K Item 1.05 / company statement) / corroborated (The Record) / unconfirmed early-warning (ransomware tracker / leak-adjacent press)
Outreach gate: confirmed → account-level plays enabled; corroborated → sector plays; unconfirmed → operator-only view, outreach suppressed, card labeled 'no company/regulator/SEC confirmation'

Builder notes

Lessons learned

What carried forward

The stdlib-only pipeline discipline — zero third-party packages for ingestion, classification, scoring, and audit — as the default for public-record tools where supply-chain surface matters. And config-as-data: seeding operator-tunable parameters into SQLite rather than hard-coding them means the next tool ships with an Admin UI and a tunable surface from day one.

Posts from this project

Case study in progress.

"A written rule is a suggestion. A gate is a control."
The operating principle behind every project here. The same bug shipped three times past written rules — and zero times past a CI gate. Deterministic enforcement beats advisory documentation, in agent harnesses and security programs alike.