1# WebSensor architecture (v0.1, 2026-09-08)23## Roles (one node today, separable by design)4| Role | Process | Notes |5|---|---|---|6| gateway + API + WebSocket | `apps/api` (`websensor-api`, :8260) | Fastify; reverse-proxies the site; Redis pub/sub fan-out |7| frontend | `apps/web` (`websensor-web`, :8261 loopback) | Next.js 16, server components call the API over loopback |8| scheduler + workers | `apps/engine` (`websensor-engine`, metrics :8262) | several engine processes can run concurrently (`FOR UPDATE SKIP LOCKED`) |9| storage | PostgreSQL 17, Redis, content-addressed blob store | Postgres = metadata/events; blobs = raw bodies, canonical text, diffs |1011## Data model (PostgreSQL)12`sources` → `sensors` → `sensor_runs`, `snapshots` → `changes` → `events` (+ `interpretations` versions,13`event_entities`, `event_clusters`), `entities` (+ `entity_aliases`, `entity_relations`, `source_entities`),14`urls` + `url_history`, `watchlists`/`watchlist_items`, `alerts`/`notifications`, `connector_health`,15`discovery_candidates`, `metrics_daily`, `llm_usage`. Full-text search: generated `tsvector` columns on16events and entities (GIN). Migrations: `packages/db/migrations/*.sql`.1718## Pipeline (per sensor run)191. **Fetch** through the connector (conditional GET with ETag/Last-Modified; 304 = cheap check). SSRF policy on20 every hop. Anti-bot 403/429/503 → Scrapfly fallback only if the source allows it and budget remains.212. **Normalize** to a comparable representation: `text` (canonical HTML/plain text), `json` (sorted keys,22 ignored volatile paths) or `list` (keyed items: feed entries, sitemap URLs, incidents, releases, records).233. **Compare** canonical hashes; unchanged → done. Else store an immutable **snapshot** (raw + canonical blob).244. **Diff** against the previous canonical blob: text (paired modifications), json (path ops), list25 (added/removed/modified with connector-specific guards: feed window, partial sitemaps).265. **Heuristics** (`evaluateChange`): noise ratio, extracted facts (prices, versions, percents, dates), type27 rules + sensor/URL priors → `signal`, `eventType`, `magnitude`. A `changes` row is always written.286. If `signal ≥ WS_MEANINGFUL_SIGNAL` and extraction is trustworthy: **event candidate** — entity resolution29 (aliases), novelty vs the 72 h window (shingle Jaccard; near-duplicates suppressed), preliminary importance,30 optional **Claude interpretation** (strict JSON: type, title, summary, why it matters, observed/inferred,31 severity, confidence, announced?), final importance (8 stored components) + confidence, silent-change flag,32 clustering (shared entity/source + textual similarity within 6 h), `events` row, Redis stream + pub/sub.337. **Schedule** the next check adaptively (tier bounds, recency of change, change frequency, 304s, errors).3435## Latency fields36`published_at` (claimed by the source) · `observed_from` (previous snapshot) · `detected_at` (fetch) ·37`processed_at` · `published_to_feed_at`; `detection_latency_ms` (only when published < 7 days before detection)38and `processing_latency_ms`.3940## Real-time41Engine → Redis stream `ws:events` (durable, MAXLEN ~20k) + channel `ws:live`. Gateway subscribes once and routes42to clients by channel: `events:global`, `events:breaking` (≥ 80), `events:silent`, `events:<ai|cyber|finance|43health|government|science|products|infrastructure>`, `type:<event_type>`, `entity:<id>`, `source:<id>`,44`watchlist:<id>` (server-side matching of the watchlist items).4546## Not in v0.1 (explicitly)47Browser rendering (Playwright), Firecrawl, PDF diffing, OpenSearch (Postgres FTS is used), MinIO (fs blob store48with the same interface), accounts/email delivery (watchlists and alerts are keyed by an anonymous owner token),49embeddings (shingle similarity is used for novelty/clustering), MCP server, customer webhooks.50