RingFacts · Living Document · as built

Architecture Overview

How the bot actually works: the hourly hunt, the three-gate dedup ladder, the claims layer, posting, operations, and the autonomous improvement loop. Began life 2026-08-06 as the step-5 design doc; converted 2026-08-08 into the current-state overview after that design shipped. Updated as the system evolves — history lives in git.

Every section opens with a grey In code strip naming the files that implement it. Line numbers drift with edits; the file names don't.

1The system at a glance

In code hunter.js the job· server.js the webhook· domain/index.js picks the domain· watchlist.js who is tracked· setup.sh + Dockerfile the deploy

RingFacts tracks a watchlist of subjects — currently three MMA fighters, called Subject A (Daniel Donchenko), Subject B (Yaroslav Amosov) and Subject C (Ilia Topuria) throughout this page — for a fan group on Telegram. There is deliberately no UI: the Telegram group is the entire product surface. One repo, one container image, two entry points: a webhook service (the future conversational rail) and the hourly hunter job — same image, different --command/--args.

The domain seam (2026-08-09). The pipeline itself no longer knows what a "fighter" is — the DB column, the watchlist field, and every internal name are subject (§3, renamed from fighter throughout the code and schema). MMA-specific knowledge — the outlet list, the official-source rule, the claim vocabulary, the prompt language — moved into one file, domain/mma.js, selected by the DOMAIN env var. A second, unrun domain (domain/example-music.js) exists purely as proof the seam holds for a different subject area, not as working configuration. The watchlist itself — who is actually tracked — is committed as watchlist.js, with watchlist.example.js documenting the shape for anyone starting their own. The Subject A/B/C labels above are shorthand for readability, mapped to real names on first use; they conceal nothing.
Cloud Scheduler 17 * * * * — dodges top-of-hour Two discovery rails Google News (en·uk·es queries) + 6 publisher feeds (2e) fighterbot-hunter Cloud Run Job · node hunter.js max-retries 0 · timeout 600s Telegram the fan group (posts) admin DM (error self-reports) Neon Postgres + pgvector items · claims · claim_sources Gemini (free tier) embeddings 768 · translation Claude Haiku claim-matcher verdicts
Fig. 1 — The whole machine. All credentials live in Secret Manager; Cloud Monitoring emails on 2+ failed runs within 2h. Everything is free tier except Haiku (pennies).
ComponentRoleNotes
fighterbot-hunterThe hourly hunt (fetch → dedup → match → post) Cloud Run Job; max-retries=0 so a buggy run fails once, visibly, instead of spamming
fighterbotTelegram webhook service Deployed, mostly dormant — becomes the step-6 conversational rail
Cloud SchedulerHourly pulse at :17 Dedicated service account whose only power is executing this one job
Neon PostgresAll memory Serverless, autosuspends; pgvector for similarity; aws-us-west-2, next door to GCP us-west1
Gemini free tierEmbeddings + translation gemini-embedding-001 at 768 dims; gemini-flash-lite-latest translates non-uk/en headlines to English
Claude HaikuThe claim matcher Forced tool use → guaranteed-parseable verdicts; the only paid component
Secret ManagerAll credentials Values flow via command substitution, never printed, never in files
The design philosophy that runs through everything: escalate intelligence before information. Cheap checks (string compare) run before geometry (embeddings), geometry before a small LLM, and the LLM only sees items the free gates couldn't settle. Same ladder, every layer.

Where the code lives

About 2,000 lines of production JavaScript, and roughly the same again in tests. There is no framework and no build step: plain ES modules, node hunter.js. The shape is one orchestrator and a set of single-purpose modules it calls — every decision the pipeline makes lives in hunter.js; every module under lib/ answers one question and holds no opinion about the run. That is the rule to keep in your head when reading any of this.

FileLinesWhat it owns
hunter.js726 The orchestrator. The order of the gates, what to do with each verdict, what gets posted and in what shape. Two exported line-formatters (digestLine, alsoMentioningLine) and one exported pipeline (huntSubject)
server.js105 The Telegram webhook. Deployed, near-dormant; the future conversational rail
lib/feeds.js
lib/googlenews.js
88
107
Discovery. Publisher RSS + name filter; decoding Google's wrapper URLs to real ones
lib/extract.js183 HTML → readable text. The four-rung body ladder and the 10,000-char stored cap
lib/embeddings.js
lib/translate.js
36
28
The two Gemini calls, one each. Thin wrappers by design
lib/matcher.js199 The one Haiku call: prompt, forced tool schema, and the normaliser that makes a verdict safe to branch on
lib/tier.js99 Headline or shared line. Pure functions, no I/O — the reason it can be replayed over the archive in a second
lib/db.js254 Every SQL statement in the project. Sixteen named functions; nothing else opens a query
lib/telegram.js
lib/chat-ids.js
36
91
Sending, and parsing the one secret that holds both chat ids
lib/subjects.js
watchlist.js
48
40
Who is tracked. Committed; watchlist.example.js documents the shape
domain/mma.js79 Everything the pipeline would have to forget to track something other than fighters: outlets, claim types, the official-source rule, prompt language
schema.sql
migrate.js

52
Three tables, and the idempotent script that applies them
test/1,211 The pipeline harness (see below), the fake store, and the real-Postgres checks
corpus/705 48 labelled articles plus the scripts that score the rules against them
The seam that matters for testing: huntSubject(db, subject, directItems, overrides). Its first act is to build a deps object — the store, the embedder, the matcher, the body fetcher, the translator, the Telegram sender — and overrides is spread over the top of it. From that line on, the pipeline never reaches for a real service by name; it calls deps.something. So the whole engine can be lifted onto a bench: hand it articles as directItems (which skips discovery entirely), swap any subset of the six dependencies, and leave the rest real. test/pipeline.test.js already does exactly this with all six faked. A bench that keeps the real matcher and points the store at a throwaway database is the same move with a different set of overrides — no refactor required.

2The two-layer model

In code schema.sql the two tables and the join· lib/db.js every read and write· domain/mma.js the claim vocabulary

The core idea of the claims layer, live since 2026-08-08: the thing the group cares about is not an article — it's the underlying fact. The red corner is the evidence world: raw articles, immutable, exactly what the hunter collects. The blue corner is the fact world: canonical claims distilled from evidence, living records whose status changes as evidence accumulates.

The problem this solved in production: three-then-seven sightings of one Masvidal quote about Subject B (MMA Junkie original → XSPORT translation → heavy.com retelling → …) — retellings kept slipping the article-level gates, and corroboration (the most important signal in news!) was being silently discarded as "duplicate."

Evidence layer — items (immutable) MMA Junkie · 13h ago "Masvidal: Amosov a 'nightmare' for Islam Makhachev" XSPORT.ua · 8h ago «Амосов — це кошмар для Махачева» — Масвідаль heavy.com · 2h ago "Amosov Touted as Future UFC Welterweight Champion" claim_sources (role: origin / echo / echo) Fact layer — claims (living) CLAIM #4 · quote · Subject B "Jorge Masvidal says Amosov would be a nightmare matchup for Islam Makhachev" status: rumor · sources: 5 · in production since bootstrap
Fig. 2 — Many sightings, one fact. The real Masvidal cluster: 5 linked sources in the live database.

Items — the evidence

  • One row per article sighted, forever
  • Immutable: never edited after insert
  • Carries: URL, headline, embedding, dedup audit trail
  • Live since 2b/2c

Claims — the facts

  • One row per real-world assertion
  • Living: status evolves with evidence
  • Carries: canonical English text, typed facts, status, own embedding
  • Live since step 5 phase 1 (2026-08-08)
Canonical English. Claims are always written in English regardless of source language. The Ukrainian article and the American article map to the same English claim text — which is also what makes claim embeddings comparable across the whole archive. Presentation language is a separate decision (§8).

3Data model

In code schema.sql the definition· migrate.js applies it (idempotent)· lib/db.js the 16 functions that are the only way in· test/sql.test.js checks them against real Postgres

Three tables, all live in Neon. items carries the evidence plus a full dedup audit trail (why was this held, against what, at what similarity); claims and claim_sources carry the fact world.

items (live) id bigint PK url text unique subject, title, source published_at, seen_at embedding vector(768) nearest_similarity, nearest_item held_reason embedding|llm|… found_via, rss_description resolved_url, body, body_via digest_tier main | tangential subject_role central|passing|… claim_sources (live) item_id → items.id claim_id → claims.id role origin | echo | corroboration | official stance asserts | denies linked_at timestamptz claims (live) id bigint PK subject text type announcement | result | injury | quote | ... canonical_text text (EN) facts jsonb status rumor | confirmed embedding vector(768) first_seen_at, confirmed_at tg_message_id bigint supersedes → claims.id n:1 n:1 One item can support several claims (an article may both announce a fight and report an injury) — hence a join table.
Fig. 3 — The schema as deployed. Arrows are foreign keys.
How to read the arrows. An arrow like item_id → items.id is a foreign key: the column holds another table's row ID — a pointer. And claim_sources is a join table: each of its rows stores not a thing but a relationship — one row reads as the sentence "article #12 is evidence for claim #4, as an echo, asserting it, linked Tuesday." Why a separate table instead of a claim_id column on articles? Because the relationship is many-to-many — one claim collects many articles (the Masvidal quote has five), one article can support several claims — and because the link carries its own facts (role, stance, when), it needs its own rows.

The columns that make the system honest:

4The hourly pipeline

In code hunter.jsmain() at 653 opens the DB and fetches the publisher feeds once, then calls huntSubject() at 262 per subject. Everything below happens inside that one function: gates at 281 / 305 / 392, the per-item loop 322–500, posting 508–651.

Every hour at :17, the hunter sweeps a 24-hour window of two discovery rails: Google News RSS per fighter per language edition, and — since 2e — six direct publisher feeds (fetched once, filtered per fighter by name; real URLs, and an independent rail if Google sheds our IP). The window overlapping the hourly cadence ~24× is the reliability trick: any single failed run loses nothing, because the next run re-covers it. Each unseen item then descends the ladder — cheap gates first, and network only for items that earned it:

Discovery ×2 Google News + 6 outlets Gate 1 — URL either identity seen → skip Gate 2 — Embedding sim ≥ 0.80 → held Body step (2e) decode URL · fetch · extract Gate 3 — Matcher Haiku + body excerpt best-effort: any failure → headline-only, exactly the old pipeline Held → linked as echo inherits neighbour's claim (drift-guarded), no LLM normalizeVerdict validation gate — see §5 CLAIMS + CLAIM_SOURCES match → link as source · new → insert claim + embed canonical text POSTER translate non-uk/en → English · 🚨 ceremony · 🕵️ rumor lines · digest · ✅ threaded confirmations
Fig. 4 — One hourly run, as deployed. Amber path: held duplicates don't exit — they become claim evidence. Green box: the 2e body step, network only for items past the free gates.

Details that matter in production:

5Claim matching in detail

In code lib/matcher.js the prompt, the forced tool schema, the verdict normaliser· domain/mma.js the claim types and prompt fragments· hunter.js:392–457 acts on the verdict

The matcher is the intelligence concentrated at the top of the ladder. For each item that survives the free gates, one Haiku call receives the headline, source, timing, a body excerpt when one exists (first 1200 chars — 2e; archive items get Google's related-coverage cluster from rss_description instead) — and all active claims for that fighter, similarity-ordered ("order, don't cut": a top-3 cutoff was measured risky because metaphor headlines bury the right candidate). Forced tool use means the answer is always a parseable verdict — no JSON scraping. Five verdicts:

VerdictMeaningWhat happens
MATCHSame underlying fact as a listed claim (retelling, translation, syndication) Linked via claim_sources with stance asserts/denies; role official if the source is ufc.com, else echo. Official + asserts → confirms the claim (§6). No new post.
NEWA claim-worthy fact not in the list Claim inserted (type, canonical English sentence, structured facts, sourcing signal); canonical text embedded for future matching; posts per §8.
NO_CLAIMAbout the fighter, but asserts nothing claim-worthy — lifestyle, peripheral division mentions, reactions Article posts through the normal digest; no claim created. The group still gets its gossip.
WRONG_SUBJECTNot about this fighter at all: namesakes (an iRacing driver also named Subject B), relatives (Subject C's brother), keyword-stuffed junk Recorded with held_reason='wrong_subject', never posted, never a claim. This absorbed the planned step-3 fighter filter.
UNSURECannot decide from available evidence — abstention beats guessing Fail-open: the article posts normally, no claim invented. Matcher API errors resolve the same way.
The normalizeVerdict gate (added by the first autonomous run, 2026-08-08). Tool schemas are guidance to the model, not a guarantee — Haiku has answered with an off-menu claim type (prediction) and could MATCH a claim id it was never offered. Every verdict is validated before the pipeline trusts it: off-enum type → other; off-enum sourcing → reported (junk can never born-confirm); MATCH on an unoffered id → UNSURE (also closes a foreign-key crash path). Ids compare as strings, because Postgres returns bigints as "7" while the model answers 7 — a naive check would have silently downgraded every match.

Prompt-contract rules learned from live data (each one bought by a real failure in bootstrap testing):

6The lifecycle: rumor to confirmed

In code lib/sources.js what counts as official· hunter.js:436–443 the one place a claim flips· lib/db.js confirmClaim, setClaimMessageId (the id that makes the reply thread possible)

Statuses live on the claim, never on articles, and transitions are driven by rules, not an LLM — the LLM's job ends at "what does this article assert, and how is it sourced?" Phase 1 deliberately ships the conservative core: claims are born rumor (or born confirmed via an official source), and the only path to confirmed is ufc.com asserting it. Counting "two independent sources" waits for phase 2, when article bodies make independence detectable (§7).

RUMOR CONFIRMED DENIED phase 2 STALE phase 2 SUPERSEDED phase 2 new claim, non-official born confirmed: ufc.com announces first ufc.com asserts it — live 2nd independent chain — phase 2 credible denial no new evidence in 14 days reality changed: new claim replaces
Fig. 5 — The state machine. Solid = live today (phase 1). Dashed = designed, waiting on phase 2.
StatusEntry rule (deterministic)
RumorLive New claim from any non-official source, whatever its confidence language — conservative by design
ConfirmedLive ufc.com asserts it (born confirmed, or rumor → confirmed with a threaded ✅ reply). The official-source list is one domain because all three fighters are UFC; pflmma.com parked in config for the day a watched fighter signs elsewhere. Record trackers (Tapology, Sherdog, ESPN) are high-credibility media — they will corroborate in phase 2, never born-confirm.
DeniedPhase 2 A source with stance denies attaches and rules rank it (official denial > original outlet's walk-back > other). Stance is already captured today; the transition isn't wired.
StalePhase 2 No new evidence for ~14 days (window is a guess; MMA negotiation cycles may argue for 30)
SupersededPhase 2 New claim created with supersedes pointing here (date change, opponent change)
The armed trap. Claim #3 — Subject A's UFC Paris announcement, rumor, five sources — sits waiting for ufc.com coverage. When it arrives, the bot's first autonomous rumor→confirmed threaded reply fires with zero code changes.

7Independence — the hard 20% Phase 2

In code not built. The data it would need is already being collected: claim_sources in schema.sql, written by lib/db.js:linkClaimSource.

Designed, not built — but 2e delivered its prerequisite (bodies + real URLs), so this is now buildable. "Two sources" is worthless if the second copied the first: XSPORT translating MMA Junkie is one reporting chain, not two. Corroboration hinges on telling echoes from independent reports. Signals, cheapest first:

Default when unsure: echo. The asymmetric cost rules this: a false "confirmed" tells the group fiction is fact; a delayed confirmation just waits for the promotion's own announcement, which always comes. Bias toward skepticism — the same reasoning that made phase 1 ufc.com-only.

8What the group actually sees

In code hunter.jsdigestLine 152 and alsoMentioningLine 175 write one line each; 508–651 assembles and sends (resends, translation, ceremonies, digest, confirmations)· lib/tier.js decides headline vs shared line· lib/telegram.js the send· lib/translate.js· lib/chat-ids.js

Posting is "one message per claim event", not per article. The claim type picks the ceremony; the claim event picks the timing. Headlines post as-is when they're in the group's languages (uk/en); anything else is translated to English by Gemini and labeled "(translated from es)" — one language, never both. The headline is plain text; the source name is the clickable link.

Claim eventPost behavior
New claim, born confirmed, type announcement Live 🚨 Ceremonial standalone post, loud
New claim, rumor, lifecycle type (announcement / negotiation / injury / result) Live 🕵️ Rumor line at the top of the hourly digest — the group loves gossip, it just needs honest labeling
Rumorconfirmed Live Reply to the original rumor message (via stored tg_message_id) — the threaded story
Everything else claim-worthy or claim-free (quotes, NO_CLAIM, UNSURE) Live One line in the hourly digest, silent delivery
Corroboration attached (still rumor) Live No message — evidence recorded silently. (Display of "also covered by…" is a phase-2 taste decision: edit vs. reply.)
Rumordenied · superseding claims Phase 2 Reply to original: "❌ Fight is off per …" / "📅 Moved to June 6"

The rumor→confirmed thread, concretely (this is what the armed Subject A trap will produce):

RingFacts · Tue 14:00
🕵️ Rumor: Donchenko set for UFC Paris card on September 6 — MMA Junkie
RingFacts · Thu 09:00 — replying to ↑
Confirmed — UFC announced the bout officially. ufc.com

9Operations & reliability

In code setup.sh the whole cloud side, re-runnable· Dockerfile· migrate.js· hunter.js:713–726 the failure self-report to the admin DM· audit-*.js, verify-digest-tier.js, backfill-*.js one-off operator scripts, run by hand

The bot runs unattended; every failure mode found in production has a specific answer:

FailureAnswerPrinciple
Google News 503s (load-shedding of shared cloud IPs — the laptop gets 200s while Cloud Run gets 503s: IP reputation) Cron at :17 (dodges the top-of-hour stampede) + one retry per feed after 75s (waves outlast 30s — measured, tuned) + since 2e, six direct publisher feeds keep delivering even when Google blanks a whole run Measure, then tune — then diversify
A run fails anyway Loses nothing: the 24h fetch window means the next hourly run re-covers it. Data loss starts only after ~24h of continuous outage. Overlapping idempotent sweeps
Repeated failures Cloud Monitoring alert emails on 2+ failed attempts within 2h (single blips don't deserve inbox noise); fatal in-run errors additionally DM the admin directly — never the group Alert on patterns, not blips
Matcher LLM down or confused Fail open: UNSURE → the article still posts via article-level rules, no claim invented; normalizeVerdict catches malformed answers before they hit the database Never lose news; abstention beats invention
Wrong confirmation ever issued Status is derived state — the rules engine can recompute any claim's status from stored evidence at any time; unmerge = delete a claim_sources row and rerun Make wrong states cheap to repair
Runaway cost max-instances=1, request-based billing, Anthropic spend cap; LLM spend tracks the news rate, not the archive size Structural ceilings, not vigilance

10Cost & scale

In code the three knobs that set the bill — hunter.js:45 MAX_ITEMS_PER_SUBJECT = 5· lib/matcher.js:10 the model, :82 the 1,200-char excerpt cap· lib/extract.js:20 the 10,000-char stored body cap
ComponentVolume (measured 2026-08-09, post-2e)Monthly
Cloud Run (hunter job + dormant service)24 hunter runs/day + 4 self-improvement check-ins≈ $0 (request-billed, free tier)
Neon Postgres8.5 MB data (512 MB free-tier cap)$0 — ~60× headroom
Gemini embeddings + translation~19 embed calls/day (one per new item, incl. held dups)$0 — comfortable free-tier headroom
Haiku matcher~12 calls/day (up from ~3–10 pre-2e: direct feeds widened the funnel)~$0.30–1.50 — the only paid component

Total: still under two dollars a month. 2e added a discovery rail (6 outlet feeds, fetched free) and a body-fetch step (plain HTTP GETs to publisher sites, also free) — neither touches a billed API, so the wider funnel shows up only as more Haiku matcher calls, and even doubling that volume barely moves the total. The escalation-ladder economics hold: the archive can grow 100× and only the (free) pgvector search notices. Fits the planned $10 Anthropic cap with a wide margin. Exact vector scans are fine at this scale; an HNSW index becomes worth it around ~100K+ rows — years away.

11The autonomous improvement loop

In code prose, not code — AGENTS.md the entry point· docs/self-improvement.md the working rules· docs/checkin-log.md the run history· corpus/ the 48 labelled articles the rules are now measured against

The system maintains itself. A scheduled self-improvement session runs every ~6 hours (~18:47 / 00:47 / 06:47 / 12:47 PT — between hunter runs) and:

Hard rules baked into every run: never post to the group or any DM; never print secret values (command substitution only); never delete data. The first autonomous run already shipped real code — the normalizeVerdict gate (§5), including catching the pg-bigint-vs-JSON-number id comparison bug live.

Parked evolutions (specs written, deliberately not built): docs/sandboxed-autonomy.md — moving this loop into an ephemeral sandbox (GitHub Actions cron) with scoped credentials so even a fully poisoned run is harmless; and hourly database snapshots to an always-free GCS bucket (TODO), since Neon's free tier keeps only a 6-hour restore window.

12Roadmap & watch items

In code TODO.md is the live list; this section is its summary.

Build sequence ahead:

Standing watch items (tracked by the autonomous loop):