SignalDrift: one engine, three evidence-first signal products.
SignalDrift is a shared codebase that collects, groups, and scores recurring coverage from curated source catalogs, then shows readers the underlying stories instead of a black-box score. The same engine runs three standalone, white-labeled products: TechDrift, GamingDrift, and SportsDrift, each with its own source pack, database, and identity.
About this page. This is a public-safe account of a private repository I built and operate solo. It describes real architecture, numbers, and feature names from the codebase. It leaves out hostnames, credentials, internal infrastructure addresses, and anything else that would expose the operating environment, the way my other engineering write-ups do.
About the screenshots. All four are real captures from the live, deployed apps and admin surface — no mockups.
- Project
- SignalDrift, a shared evidence-first trend-discovery engine
- Live brands
- TechDrift (17 sources), GamingDrift (25 sources), SportsDrift (25 sources)
- Clients
- Web app per brand, plus a Kotlin Multiplatform Android app and an early SwiftUI/iOS host
- AI usage
- Laya, a self-hosted LLM service used only for bounded, advisory tasks — never for deterministic feed delivery
- Tests
- 187 automated tests, 180 passing, 7 intentionally skipped, 0 failing
- Release model
- Every change ships to a
devenvironment before promotion tomain, with brand-isolated deploy workflows
Evidence first, scoring second.
Scheduled workers pull items from each brand's configured source catalog, normalize them, and record each collection run. The pipeline groups related coverage into topics and scores them for repetition and persistence rather than raw popularity — a story mentioned once by one outlet does not become a "signal" just because it is loud. Every topic a reader sees links back to the stories that produced it, so the evidence trail stays inspectable instead of hidden behind a score.
A 96-hour collection window captures what is current, and a configurable 7–14 day freshness filter on each article's actual publication date keeps backdated or republished RSS entries out of the evidence shown for a topic — a problem that showed up in production before the filter existed.
One codebase, three isolated products.
TechDrift, GamingDrift, and SportsDrift run from the same application and analysis code, selected at runtime by a BRAND_ID environment variable. A single brand-config.mjs module centralizes everything that varies by brand: display name, tagline, page copy, cookie prefix, base URLs, legal contact, source catalog, topic-scoring thresholds, and even the admin UI's light/dark color palette.
- TechDrift — tagline "Signal in motion," 17 sources (Hacker News, Ars Technica, The Verge, TechCrunch, Wired, and others), teal admin accent.
- GamingDrift — tagline "Game trends live," 25 sources (IGN, Polygon, PC Gamer, Eurogamer, GameSpot, and more), its own palette.
- SportsDrift — tagline "Sports desk live," 25 sources (BBC Sport, ESPN, Sky Sports, MLB, Formula 1, and more), its own palette.
Sharing code without sharing data was the harder half of the problem. Each brand gets its own Postgres database, role, and storage volumes — this is not a multi-tenant row-level-security setup, it is physically separate data per brand. Browser-side state is brand-scoped too: a real production bug surfaced when a new account, created in a browser that still held a previous account's local signals, silently inherited them. The fix was brand-prefixed localStorage keys, clearing state on logout, and removing an over-eager "migrate anonymous signals" path that was quietly treating stale browser data as a legitimate first-login migration. Content-Security-Policy headers, outbound email templates, and the public API's base-URL resolution are all brand-aware for the same reason: nothing about one brand's identity should leak into another's.
Laya: an LLM that advises, never gates.
Laya is a self-hosted LLM reasoning service the pipeline calls for a small, deliberately bounded set of tasks — never for deciding what a user sees. Deterministic feed delivery never depends on Laya succeeding; if it is slow, wrong, or unavailable, the feed still works. That constraint shaped everything else about how it is used:
- Topic-pair clustering. Deciding whether two topic candidates are really the same story. A cheap statistical similarity pass runs first so Laya is only called on pairs that already look plausible — avoiding an O(n²) sweep of LLM calls across every topic pair.
- Sig/keyword normalization. Deduplicating and grouping the keywords users save ("Sigs") so near-duplicates collapse into one signal instead of fragmenting a user's saved list.
- Per-user advisory analysis. A bounded batch job that reads a capped number of users and a capped number of recent items per user, asks Laya for a decision, and records the result — bounded by max-users, max-items, timeout, and retry environment variables, with a hard cap on each so a stalled model can't block ingestion or run away on cost.
The other half of using an LLM responsibly in a pipeline is not trusting its raw output. Laya's choice-style answers come back as a probability distribution over options plus a confidence value, and a typed-decision contract normalizes that shape before any other code touches it — a live probe against the deployed instance is what revealed its actual response shape in the first place, rather than assuming a spec. Every Laya call is logged to its own run table with a prompt version, a status, and an attempted-count, so a model or prompt change is auditable after the fact instead of invisible — though today that log lives in the database, not in a dedicated admin screen. That is the honest gap in this write-up: Laya is observable to the person who can query Postgres, not yet to an operator clicking around the admin UI, which is the next piece of admin work worth doing on its own operational merits rather than for a screenshot.
An operator surface, not just a product.
A separate admin service sits alongside the consumer app for source management, moderation, and observability. Its "Today" view is built around decisions, not raw metrics: a decision queue for things like duplicate topic-cluster merges, source health (which feeds are current vs. stalled), digest delivery (sent/failed/pending/suppressed over the last 7 days), and a 30-day onboarding funnel (registered → verified → profile complete → source selected → first use). Everything else — collection run history, per-source diagnostics, scoring internals, Laya run logs — lives one level down under Data & diagnostics, kept out of the way until something actually needs attention.
Beyond monitoring, the admin surface runs real operational workflows: per-source collection configuration and flags, a waitlist-to-beta-invite flow where an admin assigns a plan tier (Free/Plus/Pro) and the system generates a time-limited signup link and email, and a digest-scheduling tool for the daily email send. None of this is exposed to end users — it is the control plane that keeps three brands' worth of sources, users, and notifications legible to one operator.
Small team, production habits.
187 automated tests currently pass (180 passing, 7 intentionally skipped, 0 failing) through Node's built-in test runner — no separate framework dependency. Every brand has its own GitHub Actions deploy workflow, isolated from the others, and changes are validated on a dev environment before promotion to main; production deployment is brand-scoped rather than one shared release train. Database migrations are version-numbered SQL files applied in order, which is how a recent production incident was caught and fixed: three migrations had been written into the wrong directory and silently never applied, breaking a trends endpoint until the files were moved into the path the migration runner actually scans.
The open bucket.
The next phase is scoped only loosely so far: letting users add their own feed URLs beyond the curated catalog, credential handling for private or paid feeds, analytics beyond the current admin dashboard, and archiving the admin metrics-snapshot table before its unbounded growth becomes a problem instead of a line item.