Overview
A multi-industry, multi-vendor pre-sales signal intelligence platform — a restack of the Python/SQLite GridSignals prototype into Next.js 14 + Supabase. Monitors 29 free public sources (SEC EDGAR, CISA KEV, HHS OCR breach portal, FDIC enforcement, NERC/FERC pages, and 24 more) to detect buying signals for 61 security products across 47 vendors, covering energy, financial services, healthcare, and cross-industry regulated entities. The core architectural additions over GridSignals: industry-keyed scoring weights, a two-hop event→capability→product reasoning chain in config-as-data CSVs, watchlist entity resolution by regulatory identifier (CIK/ticker/LEI/Wikidata QID), and seven trust invariants enforced as schema CHECK constraints and tested against the live database. Built as a single-operator portfolio/demo product — 21 database migrations, 68 test files across two runtimes (Vitest + Deno). 565 commits · 113 PRs · 11 active days; remote Supabase and Netlify deploy not yet wired as of 2026-08-25.
Project Design
Built as an explicit generalization of GridSignals — a Python/SQLite/Streamlit prototype that covered only energy and a single vendor set. Every architectural decision was a deliberate generalization: industry-keyed scoring weights instead of a flat model, a CSV-seeded capability category layer between events and products instead of hardcoded mappings, and a seller-preference filter that enforces the vendor line card without touching classifier or scoring logic. The plan orchestrator — a custom SQLite-backed conductor at tools/orchestrator/ — managed 113 PRs through chunk lifecycle, gate verdicts, and seam locks. Cross-runtime discipline between Next.js (Node) and Supabase Edge Functions (Deno) was enforced by a generated-types hash sentinel that fails CI on any type drift, including Windows CRLF variants.
Key modules
Ingestion layer
15+ Deno Edge Functions (one per public source) on pg_cron, inheriting a shared runner with idempotent dedupe via ON CONFLICT DO NOTHING, TTL skip, cursor-resumable pagination, and per-source error containment.
Entity resolver
Deterministic resolution by CIK, ticker, LEI, and Wikidata QID; difflib fuzzy fallback for names. An ambiguous CIK (two watchlist rows sharing one CIK) routes to the operator review queue, never auto-fires a signal.
Multi-vendor catalog + seller filter
61 products across 47 vendors in CSV seeds; a per-vendor carried/not-carried preference layer enforces the seller's line card without touching recommendation logic. 35 vendors marked carried; 12 excluded for direct-only motion or partner-agreement conflicts.
Two-hop recommendation engine
Event-type-to-capability-category mapping in a CSV; capability-to-product mapping via the seeded products table filtered by seller preferences. No product name hardcoded in any classifier or scoring function.
SQL scoring function
Decay formula (base_strength × 0.5^(age_days/half_life) × account_fit × scope_fit) lives in a single authoritative migration (0008); no TypeScript reimplementation. Industry and subsector weights are keyed rows in scoring_weights.csv.
Stack inference (infer-stack)
Reads each watchlist entity's EDGAR 10-K Item 1C cybersecurity filing to infer incumbent vendor stack using word-boundary regex — incumbent context for the recommendation engine, zero paid data sources.
Config seeds
20 CSV files seeded via scripts/seed-loader.mjs. Products, capability categories, scoring weights, watchlist entities, and vendor preferences are admin-editable without a code deploy.
Key features
Per-industry scoring weights
The core v2 addition over GridSignals. Migration 0008 documents it explicitly: "ADDED for v2: the per-industry dimension (scoring_weights is keyed by industry since 0002), which the original had no concept of." Each row in scoring_weights.csv keys on industry + weight_kind + subsector — iou_electric (NERC CIP registered entities) scores at 1.10 for energy; midstream at 0.95 (TSA pipeline directives); equivalent rows exist for financial_services and healthcare subsectors. An event touching an unknown subsector falls back to a neutral 1.0 weight, never errors. The same scoring function handles all three industries.
Two-hop config-as-data recommendation
The multi-vendor reasoning path: event_type → capability_category → products. First hop lives in event_type_to_category.csv — an editable mapping, not code. Second hop is the seeded products table filtered by the seller's vendor preferences CSV. A healthcare entity triggering an HHS OCR breach enforcement event routes to identity_access and grc_compliance capability categories; the recommendation engine then surfaces the relevant vendor subset from the seller's carried line card, with a trigger class (reactive/compliance/opportunity) explaining the sales motion. Adding a vendor, swapping a product, or adjusting a capability mapping requires no code deploy and no classifier change — only a seed update.
Security & ops decisions
- Row-level security on all write paths (migration 0010_rls.sql); the anonymous role can SELECT only the four card-path tables — signals, watchlist_entities, signal_evidence, recommendation_snapshots.
- Seven trust invariants locked as schema CHECK constraints and tested by a DB-level test harness: no price in the DOM, nothing surfaces unsourced, unconfirmed_early_warning cards never customer-facing, recommendation snapshots append-only, no paid or LLM call on any read path, market position shown only with a named analyst source and verified date.
- Service-role leak scan (scripts/check-service-role-leak.mjs) and a generated-types content-hash sentinel run on every CI pass alongside TypeScript and Deno typechecks.
- No paid data sources: 29 source policies are all free, read-only public APIs and pages with verified terms of service. The only recurring cost is a capped Claude API audit judge — a scheduled Edge Function, never in the read path.
Builder notes
- The plan orchestrator (tools/orchestrator/ — a custom SQLite-backed conductor, not shipped as part of the product) managed 113 PRs across 11 active days: chunk lifecycle, gate verdicts, seam locks, and reconcile passes. A 565-commit build with parallel tracks needs a conductor.
- Cross-runtime parity between Next.js (Node) and Supabase Edge Functions (Deno) was enforced by shared types alone — a content-hash sentinel on generated Supabase TypeScript types fails CI on any drift, including CRLF variants on Windows (commit a7ec7b09). Shared import paths are a runtime error; shared types across a CI boundary are not.
- 21 products were downgraded to unranked/none in commit f22c5047 after a post-seed review found market positions that couldn't be confirmed from their cited source. The market-position honesty gate (R4.10) is a schema guard and a marketPosition.ts runtime check — not documentation.
Lessons learned
- Score authority in one place is an architectural rule, not a style preference. Two implementations that agree on demo fixtures and diverge in production make a card whose rendered breakdown doesn't multiply to its own score. DN1 — SCORING LIVES HERE AND NOWHERE ELSE — locked scoring in SQL from day one.
- Watchlist resolution by regulatory identifier, not name, is load-bearing. Company names are ambiguous and change; CIK/ticker/LEI/Wikidata QID are stable. The Python predecessor silently collapsed CIK collisions (dict last-write-wins); the Deno port surfaces them as an explicit operator review queue that blocks auto-fire.
- Config-as-data compounds: when products, capability categories, scoring weights, and vendor preferences live in CSV seeds, a seller adjusts their line card without a code deploy — and the test suite asserts the seed invariants directly against the live schema.
- Cross-runtime CI hygiene matters from the first commit. A content-hash sentinel on generated Supabase TypeScript types caught a Windows CRLF false-positive (commit a7ec7b09) before it could silently drift both runtimes out of alignment.
What carried forward
The two-hop config-as-data reasoning chain — event→capability→product with no hardcoded product names — and the principle that trust invariants belong in the schema (CHECK constraints + append-only triggers) rather than in application code.
Posts from this project
Case study in progress.