From 0c65690c16cb2e4779158139445dc9d1282803cb Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Tue, 25 Aug 2026 21:09:36 -0500 Subject: [PATCH] feat: fix untickered-instrument aggregation + redesign the Edge Board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregation - Accept instruments without a ticker symbol (indices, sovereign bonds, FX, commodities, private/foreign issuers). The parser required a non-empty symbol, so the model's valid untickered events were rejected and their posts stuck at max attempts. Symbol is now optional (require name+type); primary-instrument identity falls back to the name. Regression tests added. Dashboard - New derived "unconfirmed" status: a breaking event past a tunable TTL (FINTICK_BREAKING_TTL_SECONDS, default 1h) the wire never corroborated. Derived in read_feed from time — no DB migration. - Multi-select filter pills moved into a sticky, translucent glass top nav. - Light/dark theming: system-aware plus a manual toggle persisted per-browser. - Ticker moved inline (full-width, edge-fade mask); headlines are clickable anchors that scroll to the matching event card. - Operator telemetry (pipeline-health strip + status pill) hidden for public visitors; reveal with ?ops (persisted; ?ops=0 clears). - De-duplicated the TERMINAL ERRORS indicator to the header only. Co-Authored-By: Claude Opus 4.8 --- fintick/aggregate.py | 22 ++++-- fintick/dashboard.py | 136 +++++++++++++++++++++++++++++------- tests/test_acceptance_v2.py | 8 +++ tests/test_aggregate.py | 49 +++++++++++++ tests/test_dashboard.py | 29 +++++++- 5 files changed, 210 insertions(+), 34 deletions(-) diff --git a/fintick/aggregate.py b/fintick/aggregate.py index 68e8cd7..573b694 100644 --- a/fintick/aggregate.py +++ b/fintick/aggregate.py @@ -43,7 +43,9 @@ SYSTEM_PROMPT = """You aggregate posts from ONE fast financial stream into distinct real-world events. Return JSON with exactly two top-level arrays: events and ignored_posts. Each event must contain: canonical_headline (concise), summary (one sentence), importance (integer 1-5), -instruments (array of {symbol,name,type,direction}; direction is up/down/flat), +instruments (array of {symbol,name,type,direction}; direction is up/down/flat; symbol is the +ticker when one exists, otherwise leave it "" — indices, government bonds, FX, and commodities +usually have no ticker and are identified by name and type; never invent a ticker), facts (array of {label,value} for concrete claims such as percentage moves, counts, prices, dates), and post_ids (the exact short input IDs supporting that event). Each ignored_posts item must be {"id":"p001","reason":"brief concrete reason"}. @@ -141,8 +143,14 @@ def _parse_event( name = item.get("name") instrument_type = item.get("type") direction = str(item.get("direction", "")).lower().strip() - if not isinstance(symbol, str) or not symbol.strip(): - raise ValueError("each instrument requires a symbol") + # Symbol is OPTIONAL: indices, sovereign bonds, FX, commodities, and many + # private or foreign issuers have no ticker. Such an instrument is identified + # by name + type — require those, and normalize a symbol only when one is given. + if symbol is not None and not isinstance(symbol, str): + raise ValueError("instrument symbol must be a string when present") + normalized_symbol = ( + symbol.strip().lstrip("$").upper() if isinstance(symbol, str) else "" + ) if not isinstance(name, str) or not name.strip(): raise ValueError("each instrument requires a name") if not isinstance(instrument_type, str) or not instrument_type.strip(): @@ -150,7 +158,7 @@ def _parse_event( if direction not in DIRECTIONS: raise ValueError("instrument direction must be up, down, or flat") instruments.append({ - "symbol": symbol.strip().lstrip("$").upper(), + "symbol": normalized_symbol, "name": name.strip(), "type": instrument_type.strip().lower(), "direction": direction, @@ -191,7 +199,11 @@ def _parse_event( raise ValueError("importance must be between 1 and 5") timestamps = [post_times[uri] for uri in uris] - primary = instruments[0]["symbol"] if instruments else None + # Fall back to the instrument name when it carries no ticker, so events still + # get a stable, meaningful identity component (indices, bonds, FX, commodities). + primary = None + if instruments: + primary = instruments[0]["symbol"] or instruments[0]["name"] return V2Event.from_key( headline.strip(), summary.strip(), diff --git a/fintick/dashboard.py b/fintick/dashboard.py index 2db4d3a..78985fe 100644 --- a/fintick/dashboard.py +++ b/fintick/dashboard.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import sqlite3 from datetime import UTC, datetime from http import HTTPStatus @@ -16,7 +17,28 @@ DEFAULT_LIMIT = 100 MAX_LIMIT = 250 -STATUS_ORDER = {"breaking": 0, "contradicted": 1, "developing": 2, "confirmed": 3} +# 'unconfirmed' sinks to the bottom: it is breaking that the wire never caught up on. +STATUS_ORDER = { + "breaking": 0, "contradicted": 1, "developing": 2, "confirmed": 3, "unconfirmed": 4, +} +# A breaking event ages into 'unconfirmed' once the wire has had this long to catch up +# and still hasn't. Purely a passage-of-time lens over the stored validation fact +# (breaking = no corroboration found), so the flip needs no re-hunt and no DB write. +BREAKING_TTL_SECONDS = int(os.environ.get("FINTICK_BREAKING_TTL_SECONDS", "3600")) + + +def _effective_status(status: Any, first_seen_at: Any, now: datetime) -> str: + """Derive the display status: breaking past its TTL reads as 'unconfirmed'.""" + if status != "breaking": + return str(status) + try: + first = datetime.fromisoformat(str(first_seen_at).replace("Z", "+00:00")) + except (TypeError, ValueError): + return "breaking" + if first.tzinfo is None: + return "breaking" + age = (now - first.astimezone(UTC)).total_seconds() + return "unconfirmed" if age >= BREAKING_TTL_SECONDS else "breaking" def _safe_validations(value: Any) -> list[dict[str, Any]]: @@ -70,8 +92,12 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, events = load_events(connection, limit=None) pipeline = load_pipeline_health(connection) pipeline["database_identity"] = database_identity(database) + now = datetime.now(UTC) for event in events: event["validations"] = _safe_validations(event.get("validations")) + event["status"] = _effective_status( + event.get("status"), event.get("first_seen_at"), now + ) events.sort(key=lambda event: ( STATUS_ORDER.get(str(event.get("status")), 9), str(event.get("first_seen_at", "")), @@ -79,7 +105,7 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, )) # Keep each status group newest-first without letting recency outrank urgency. ordered: list[dict[str, Any]] = [] - for status in ("breaking", "contradicted", "developing", "confirmed"): + for status in ("breaking", "contradicted", "developing", "confirmed", "unconfirmed"): group = [event for event in events if event.get("status") == status] group.sort(key=lambda event: (str(event.get("first_seen_at", "")), int(event["id"])), reverse=True) ordered.extend(group) @@ -98,46 +124,91 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, - + FinTick — The Edge Board +
-

FinTick_

ahead of the wire
CONNECTING
-
Awaiting pipeline health…
-
+
+

FinTick_

ahead of the wire
+
+
CONNECTING
+
+
+
Awaiting pipeline health…
+
+
-

The Edge Board

What the stream caught—and whether the news has caught up.

Awaiting events…
Loading the edge…
@@ -148,17 +219,30 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str, const $=id=>document.getElementById(id); function element(tag,className,text){const node=document.createElement(tag);if(className)node.className=className;if(text!==undefined)node.textContent=text;return node} function relativeTime(value){const time=Date.parse(value);if(!Number.isFinite(time))return'time unknown';const s=Math.max(0,Math.round((Date.now()-time)/1000));if(s<60)return s+'s ago';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';return Math.floor(s/86400)+'d ago'} -function safeStatus(value){return['breaking','confirmed','contradicted','developing'].includes(value)?value:'developing'} -function badgeText(item){const n=Array.isArray(item.validations)?item.validations.length:0;switch(safeStatus(item.status)){case'breaking':return'BREAKING — no corroboration yet';case'confirmed':return'CONFIRMED — '+n+' source'+(n===1?'':'s');case'contradicted':return'CONTRADICTED';default:return'DEVELOPING'}} +function safeStatus(value){return['breaking','confirmed','contradicted','developing','unconfirmed'].includes(value)?value:'developing'} +function badgeText(item){const n=Array.isArray(item.validations)?item.validations.length:0;switch(safeStatus(item.status)){case'breaking':return'BREAKING — no corroboration yet';case'unconfirmed':return'UNCONFIRMED — wire still silent';case'confirmed':return'CONFIRMED — '+n+' source'+(n===1?'':'s');case'contradicted':return'CONTRADICTED';default:return'DEVELOPING'}} function lagText(seconds){if(!Number.isFinite(seconds))return'';const abs=Math.abs(seconds),value=abs<3600?Math.round(abs/60)+' min':(abs/3600).toFixed(1)+' hr';return seconds>=0?'news +'+value+' after the stream':'news '+value+' before the stream'} -function renderPipeline(value){const p=value&&typeof value==='object'?value:{},node=$('pipeline-health'),backlog=Number(p.backlog)||0,errors=Number(p.terminal_errors)||0,accounted=Number(p.accounted)||0,posts=Number(p.posts)||0;node.replaceChildren();let state='CAUGHT UP',tone='good';if(errors>0){state='TERMINAL ERRORS';tone='bad'}else if(backlog>0){state='CATCHING UP';tone='warn'}const status=element('span',tone);status.append(element('b','',state));node.append(status);const coverage=element('span','');coverage.append(document.createTextNode('accounted '),element('b','',accounted+' / '+posts));node.append(coverage);const queue=element('span',backlog?'warn':'good');queue.append(document.createTextNode('backlog '),element('b','',String(backlog)));node.append(queue);if(backlog&&p.oldest_pending_at){const oldest=element('span','');oldest.append(document.createTextNode('oldest '),element('b','',relativeTime(p.oldest_pending_at)));node.append(oldest)}if(errors){const terminal=element('span','bad');terminal.append(document.createTextNode('errors '),element('b','',String(errors)));node.append(terminal)}$('connection').textContent=state;$('pulse').classList.toggle('catchup',backlog>0&&!errors);$('pulse').classList.toggle('error',errors>0)} -function renderMetrics(items){const metrics=$('metrics');metrics.replaceChildren();for(const [status,label] of [['breaking','breaking now'],['confirmed','confirmed'],['developing','developing'],['contradicted','contradicted']]){const box=element('div','metric '+status);box.append(element('b','',String(items.filter(x=>x.status===status).length)),element('span','',label));metrics.append(box)}} -function renderTape(items){const tape=$('tape');tape.replaceChildren();if(!items.length){tape.append(element('span','tick','No events yet'));return}function group(hidden){const node=element('div','tape-group');if(hidden)node.setAttribute('aria-hidden','true');for(const item of items.slice(0,24))node.append(element('span','tick '+safeStatus(item.status),String(item.headline||'')));return node}tape.append(group(false),group(true));tape.style.setProperty('--duration',Math.max(38,items.length*8)+'s')} -function renderCard(item){const status=safeStatus(item.status),card=element('article','card '+status);const meta=element('div','card-meta');meta.append(element('span','badge',badgeText(item)));if(Number.isInteger(item.importance)){const rank=element('span','importance');rank.setAttribute('aria-label','Importance '+item.importance+' of 5');rank.append(document.createTextNode('◆'.repeat(item.importance)),element('i','', '◆'.repeat(5-item.importance)));meta.append(rank)}card.append(meta,element('h3','headline',String(item.headline||'Untitled event')));if(item.summary)card.append(element('p','summary',String(item.summary)));const origin=element('p','origin');origin.append(document.createTextNode('via the stream · seen '),element('strong','',String(item.stream_seen||0)+'×'),document.createTextNode(' · '+relativeTime(item.first_seen_at)));card.append(origin); +function renderPipeline(value){const p=value&&typeof value==='object'?value:{},node=$('pipeline-health'),backlog=Number(p.backlog)||0,errors=Number(p.terminal_errors)||0,accounted=Number(p.accounted)||0,posts=Number(p.posts)||0;node.replaceChildren();let state='CAUGHT UP';if(errors>0){state='TERMINAL ERRORS'}else if(backlog>0){state='CATCHING UP'}const coverage=element('span','');coverage.append(document.createTextNode('accounted '),element('b','',accounted+' / '+posts));node.append(coverage);const queue=element('span',backlog?'warn':'good');queue.append(document.createTextNode('backlog '),element('b','',String(backlog)));node.append(queue);if(backlog&&p.oldest_pending_at){const oldest=element('span','');oldest.append(document.createTextNode('oldest '),element('b','',relativeTime(p.oldest_pending_at)));node.append(oldest)}if(errors){const terminal=element('span','bad');terminal.append(document.createTextNode('errors '),element('b','',String(errors)));node.append(terminal)}$('connection').textContent=state;$('pulse').classList.toggle('catchup',backlog>0&&!errors);$('pulse').classList.toggle('error',errors>0)} +const FILTERS=[['all','all'],['breaking','breaking'],['unconfirmed','unconfirmed'],['developing','developing'],['confirmed','confirmed'],['contradicted','contradicted']]; +const activeFilters=new Set(); +function statusCount(items,status){return status==='all'?items.length:items.filter(x=>x.status===status).length} +function renderMetrics(items){const metrics=$('metrics');metrics.replaceChildren();for(const [status,label] of FILTERS){const on=status==='all'?activeFilters.size===0:activeFilters.has(status),box=element('button','metric '+status+(on?' active':''));box.type='button';box.setAttribute('aria-pressed',String(on));box.append(element('span','',label),element('b','',String(statusCount(items,status))));box.addEventListener('click',()=>{if(status==='all')activeFilters.clear();else if(activeFilters.has(status))activeFilters.delete(status);else activeFilters.add(status);applyFilter()});metrics.append(box)}} +function tick(item,hidden){const cls='tick '+safeStatus(item.status),text=String(item.headline||'');if(item.id===undefined||item.id===null)return element('span',cls,text);const link=element('a',cls,text);link.href='#evt-'+item.id;link.title='Jump to this event';if(hidden)link.tabIndex=-1;return link} +function renderTape(items){const tape=$('tape');tape.replaceChildren();if(!items.length){tape.append(element('span','tick','No events yet'));return}function group(hidden){const node=element('div','tape-group');if(hidden)node.setAttribute('aria-hidden','true');for(const item of items.slice(0,24))node.append(tick(item,hidden));return node}tape.append(group(false),group(true));tape.style.setProperty('--duration',Math.max(38,items.length*8)+'s')} +function renderCard(item){const status=safeStatus(item.status),card=element('article','card '+status);if(item.id!==undefined&&item.id!==null)card.id='evt-'+item.id;const meta=element('div','card-meta');meta.append(element('span','badge',badgeText(item)));if(Number.isInteger(item.importance)){const rank=element('span','importance');rank.setAttribute('aria-label','Importance '+item.importance+' of 5');rank.append(document.createTextNode('◆'.repeat(item.importance)),element('i','', '◆'.repeat(5-item.importance)));meta.append(rank)}card.append(meta,element('h3','headline',String(item.headline||'Untitled event')));if(item.summary)card.append(element('p','summary',String(item.summary)));const origin=element('p','origin');origin.append(document.createTextNode('via the stream · seen '),element('strong','',String(item.stream_seen||0)+'×'),document.createTextNode(' · '+relativeTime(item.first_seen_at)));card.append(origin); const facts=Array.isArray(item.facts)?item.facts:[];if(facts.length){const grid=element('div','facts');for(const fact of facts){if(!fact||fact.value===undefined)continue;const node=element('div','fact');node.append(element('b','',String(fact.value)+(fact.unit?' '+fact.unit:'')),element('span','',String(fact.label||'fact')));grid.append(node)}if(grid.childNodes.length)card.append(grid)} const instruments=Array.isArray(item.instruments)?item.instruments:[];if(instruments.length){const chips=element('div','chips');for(const instrument of instruments){const direction=['up','down','flat'].includes(instrument.direction)?instrument.direction:'flat';const marker=direction==='up'?'▲ ':direction==='down'?'▼ ':'— ';const chip=element('span','chip '+direction,marker+String(instrument.symbol||instrument.name||''));chip.title=String(instrument.name||instrument.symbol||'Instrument');chips.append(chip)}card.append(chips)} const links=Array.isArray(item.validations)?item.validations:[];if(links.length){const list=element('ul','sources');for(const story of links){if(!story||!story.url)continue;const li=element('li'),link=element('a','',String(story.title||story.url));link.href=String(story.url);link.target='_blank';link.rel='noopener noreferrer';li.append(link,document.createTextNode(' '),element('span','publisher','— '+String(story.publisher||'external news')));list.append(li)}if(list.childNodes.length)card.append(list)}const lag=lagText(item.lead_seconds);if(lag)card.append(element('p','lag',lag));return card} -function render(data){const items=Array.isArray(data.items)?data.items:[],feed=$('feed');renderPipeline(data.pipeline);renderMetrics(items);renderTape(items);feed.replaceChildren();if(!items.length)feed.append(element('div','empty','No aggregated events yet. Ingest the stream, then run FinTick aggregate.'));else for(const item of items)feed.append(renderCard(item));const generated=new Date(data.generated_at);$('updated').textContent=Number.isNaN(generated.valueOf())?items.length+' events':items.length+' events · updated '+generated.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})} +let lastItems=[],lastGeneratedAt=null; +function visibleItems(){return activeFilters.size===0?lastItems:lastItems.filter(x=>activeFilters.has(x.status))} +function renderFeed(){const feed=$('feed'),items=visibleItems(),sel=[...activeFilters];feed.replaceChildren();if(!items.length)feed.append(element('div','empty',lastItems.length?('No '+(sel.length?sel.join(' / ')+' ':'')+'events to show.'):'No aggregated events yet. Ingest the stream, then run FinTick aggregate.'));else for(const item of items)feed.append(renderCard(item));const generated=new Date(lastGeneratedAt),total=lastItems.length,shown=items.length,count=sel.length===0?total+' events':shown+' of '+total+' · '+sel.join(' + '),stamp=Number.isNaN(generated.valueOf())?'':' · updated '+generated.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('updated').textContent=count+stamp} +function applyFilter(){renderMetrics(lastItems);renderFeed()} +function render(data){lastItems=Array.isArray(data.items)?data.items:[];lastGeneratedAt=data.generated_at;renderPipeline(data.pipeline);renderTape(lastItems);applyFilter()} +function currentTheme(){const set=document.documentElement.dataset.theme;if(set==='light'||set==='dark')return set;return matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'} +function syncThemeIcon(){$('theme-toggle').textContent=currentTheme()==='light'?'☀':'☾'} +function toggleTheme(){const next=currentTheme()==='light'?'dark':'light';document.documentElement.dataset.theme=next;try{localStorage.setItem('fintick-theme',next)}catch(e){}syncThemeIcon()} +$('theme-toggle').addEventListener('click',toggleTheme);syncThemeIcon(); +matchMedia('(prefers-color-scheme: light)').addEventListener('change',()=>{if(!document.documentElement.dataset.theme)syncThemeIcon()}); let refreshInFlight=false;async function refresh(){if(refreshInFlight)return;refreshInFlight=true;try{const response=await fetch('/api/feed',{cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);render(await response.json())}catch(error){$('connection').textContent='RECONNECTING';$('pulse').classList.add('error');if(!$('feed').querySelector('.card'))$('feed').replaceChildren(element('div','empty error-message','Event feed unavailable. FinTick will retry automatically.'))}finally{refreshInFlight=false}} refresh(); setInterval(refresh, 20000); diff --git a/tests/test_acceptance_v2.py b/tests/test_acceptance_v2.py index bf94614..27cec41 100644 --- a/tests/test_acceptance_v2.py +++ b/tests/test_acceptance_v2.py @@ -5,6 +5,7 @@ import json import tempfile import unittest +from datetime import UTC, datetime, timedelta from pathlib import Path from fintick.aggregate import aggregate_once @@ -43,6 +44,13 @@ def test_nvda_signal_reaches_breaking_event_board_offline(self) -> None: }]}) aggregated = aggregate_once(database, call_model=lambda _: model_response) + # The fixture's post timestamps are historical; keep the event fresh so the + # board shows it breaking rather than aged into unconfirmed (see BREAKING_TTL). + with open_database(database) as connection: + connection.execute( + "UPDATE events SET first_seen_at = ? WHERE status = 'breaking'", + ((datetime.now(UTC) - timedelta(minutes=1)).isoformat(),), + ) validated = validate_pending(database, lookup=lambda _query, _limit: [], min_age=0) board = read_feed(database) diff --git a/tests/test_aggregate.py b/tests/test_aggregate.py index 61086a4..d106da3 100644 --- a/tests/test_aggregate.py +++ b/tests/test_aggregate.py @@ -239,6 +239,55 @@ def test_rejects_factless_event(self) -> None: self.assertEqual(parsed.events, ()) self.assertEqual(parsed.errored, 1) + def test_accepts_instrument_without_a_ticker_symbol(self) -> None: + # Indices, sovereign bonds, FX, and commodities have no ticker; the model + # identifies them by name + type and leaves symbol "" (or omits it). Both + # forms must parse — this is the exact class of event that was erroring out. + for symbol in ("", None): + with self.subTest(symbol=symbol): + instrument = { + "name": "Brent crude", + "type": "commodity", + "direction": "down", + } + if symbol is not None: + instrument["symbol"] = symbol + parsed = parse_aggregation( + json.dumps({"events": [_event( + canonical_headline="Brent crude falls below $88 per barrel", + instruments=[instrument], + )]}), + allowed_uris=set(URIS), + post_times={ + uri: f"2026-08-24T15:0{index}:00+00:00" + for index, uri in enumerate(URIS) + }, + ) + self.assertEqual(parsed.errored, 0) + self.assertEqual(len(parsed.events), 1) + self.assertEqual(parsed.events[0].instruments[0]["symbol"], "") + self.assertEqual(parsed.events[0].instruments[0]["name"], "Brent crude") + + def test_untickered_event_keys_on_instrument_name_not_empty(self) -> None: + # Two distinct untickered events must not collide on an empty instrument key. + brent = parse_aggregation( + json.dumps({"events": [_event( + canonical_headline="Brent crude falls", + instruments=[{"name": "Brent crude", "type": "commodity", "direction": "down"}], + )]}), + allowed_uris=set(URIS), + post_times={uri: f"2026-08-24T15:0{i}:00+00:00" for i, uri in enumerate(URIS)}, + ) + yuan = parse_aggregation( + json.dumps({"events": [_event( + canonical_headline="PBOC sets yuan midpoint", + instruments=[{"name": "Chinese yuan", "type": "fx", "direction": "flat"}], + )]}), + allowed_uris=set(URIS), + post_times={uri: f"2026-08-24T15:0{i}:00+00:00" for i, uri in enumerate(URIS)}, + ) + self.assertNotEqual(brent.events[0].key, yuan.events[0].key) + class AggregatePipelineTests(unittest.TestCase): def test_accounted_batch_persists_assignments_and_ignores_then_does_not_repeat(self) -> None: diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index fc969cf..000ea04 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -4,6 +4,7 @@ import json from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta import tempfile import threading import unittest @@ -11,7 +12,7 @@ import urllib.request from pathlib import Path -from fintick.dashboard import DASHBOARD_HTML, DashboardServer, read_feed +from fintick.dashboard import BREAKING_TTL_SECONDS, DASHBOARD_HTML, DashboardServer, read_feed from fintick.ingest import ingest_fixture from fintick.service_handoff import snapshot_database from fintick.storage import ( @@ -50,8 +51,9 @@ def setUp(self) -> None: },), importance=4, post_uris=uris, - first_seen_at="2026-08-24T15:00:11+00:00", - last_seen_at="2026-08-24T15:08:22+00:00", + # Fresh, so it is genuinely breaking (past the TTL it reads as unconfirmed). + first_seen_at=(datetime.now(UTC) - timedelta(minutes=2)).isoformat(), + last_seen_at=datetime.now(UTC).isoformat(), )) @@ -131,6 +133,27 @@ def test_breaking_events_sort_ahead_of_newer_confirmed_events(self) -> None: self.assertEqual(items[1]["validations"][0]["feed_type"], "rss") self.assertEqual(items[1]["lead_seconds"], 300) + def test_stale_breaking_event_reads_as_unconfirmed(self) -> None: + # A breaking event the wire never caught up on ages into 'unconfirmed' + # purely with time — no re-validation, no stored-status change. + stale_at = ( + datetime.now(UTC) - timedelta(seconds=BREAKING_TTL_SECONDS + 120) + ).isoformat() + with open_database(self.database) as connection: + connection.execute( + "UPDATE events SET first_seen_at = ?, status = 'breaking' WHERE id = ?", + (stale_at, self.event_id), + ) + + event = read_feed(self.database)["items"][0] + + self.assertEqual(event["status"], "unconfirmed") + with open_database(self.database) as connection: + stored = connection.execute( + "SELECT status FROM events WHERE id = ?", (self.event_id,) + ).fetchone()[0] + self.assertEqual(stored, "breaking") # derived for display only + def test_limit_validation_and_cap(self) -> None: with self.assertRaises(ValueError): read_feed(self.database, limit=0)