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.
Key modules
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.
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.
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.
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'.
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.
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.
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.'
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
- All 13 data sources are public-record only — GET/RSS/JSON, no ToS-restricted scraping, no credential bypass. User-agent identifies the tool: GridSignals/0.1 (+https://github.com/Doogit/GridSignals).
- Single-writer ingestion lock serializes all database writes and is released on SIGTERM via a two-pass /proc relay to cron-spawned descendants, preventing stale lock files from blocking the next container startup.
- Incident outreach is gated by evidence tier: confirmed (8-K Item 1.05 or company statement) enables account-level product plays; corroborated (security journalism) enables sector-level plays; unconfirmed early-warning (ransomware tracker) is operator-only, outreach suppressed, card labeled with the absence of confirmation.
- Security Copilot plays are suppressed for known or likely US government cloud tenants.
- PII guard enforced at commit time via .git/hooks/pre-commit and commit-msg hooks scanning staged content and commit messages against a pattern file.
- Every Admin config edit is appended to a config_audit table — an immutable provenance trail of every weight or half-life change, with the config version stamped per scoring run (migration 0011) for reproducibility.
Builder notes
- The SIGTERM relay in deploy/entrypoint.sh required a two-pass /proc walk to signal every cron-spawned job descendant — tini's -g flag does not reach processes that cron places in separate sessions. The function is tested against a real three-level process tree in test_packaging.py:623–727.
- Entity resolution uses a 25-case adversarial fixture set (tests/fixtures/adversarial_cases.csv) covering bare-name collisions ('Dominion', 'Chord', 'Range', 'Constellation'), CIK zero-padding, ticker case-insensitivity, fuzzy typos, suffix-strip near-twins, and an unknown-company case.
- The Streamlit UI was replaced mid-build with FastAPI + HTMX + Tailwind; test_packaging.py pins Streamlit's absence at line ~332 as a packaging regression guard so it cannot quietly reappear as a transitive dependency.
Lessons learned
- A stdlib-only constraint is not just a dependency policy — it forces safer code paths. The combo engine needed a composable rule grammar; ruling out eval/exec produced a hand-written logic_expr parser that is auditable, testable, and grammatically bounded by construction.
- Config-as-data beats hard-coded logic for an operator-facing tool: products, triggers, scoring weights, watchlist entities, and combo rules all seed from CSVs into SQLite. The operator tunes the system through the Admin UI without a code deploy, and a new instance comes up fully configured from the seed files alone.
- Honest empty states are documentation, not embarrassment. The account-specific tier measured 0 of 1,886 EDGAR submissions with a material cybersecurity incident (8-K Item 1.05) — not a broken classifier, a structural truth about filing frequency. Publishing the exact count and the structural reason preempts a misfiled bug and explains the gating logic in one sentence.
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.