Token News (spec 109)¶
Token news is the platform's fourth read-proxy (after Collect, Predict, and Perps): recent headlines for one asset, rendered on the portfolio's asset sheet and under the Trade pair, and offered to the assistant as exactly one tool-pull tool. It is ADVISORY-ONLY — no value path gates on it, and no news state may disable, delay, or alter a form.
Architecture¶
frontend relay-gateway vendor
───────── ───────────── ──────
newsAssets.js (chainId,address) src/news/routes.js Alphaday
→ slug | null GET /v1/news/:chainId/:asset /items/news/?tags=<slug>
lib/news/newsClient.js ──HTTP──▶ killswitch → enabled → (keyless, no CORS,
TokenNewsCard (both mounts) validate → quota → cache no contract identity)
- The gateway proxy is mandatory, not an optimization: the vendor serves no CORS headers, so
a browser can never call it directly (research R1). The module is
NEWS_ENABLED(default off); off, the route answers503 news_unconfiguredand every frontend surface renders nothing. - The vendor has no contract identity anywhere — its tags and coins are slug + ticker only.
The
(chainId, address) → slugmapping is therefore OURS, and it is the load-bearing curation point (below).
The mapping table: frontend/src/config/newsAssets.js¶
One curated table beside the asset registry (the spec-108 offered-beside-resolvable precedent). Rules:
- Every row is probe-verified before it is written. A wrong slug fails closed to
[]upstream — verified live — which renders as honest-empty, i.e. it silently costs the member a feed. Record the verification date in the row's comment. The probes that seeded the table are inspecs/109-token-news/research.md(R3), including the non-obvious ones: POL ismatic-network(polygon,matic, andpolall return zero items), USDC isusd-coin(usdcis empty), WBTC iswrapped-bitcoin. - A missing row IS "not covered" — resolved client-side with no network call, rendered as the honest absence sentence. Never add a ticker heuristic or a fallback; a guessed slug that happens to exist would show a member news about a different asset.
- Wrapped natives resolve through the registry's
baselineSymbol(WETC → ETC →ethereum-classic), so a wrapper never needs its own row. - Bitcoin string network ids (spec 061) resolve directly; they never touch EVM-shaped paths.
The FeedReading¶
Three states plus a distinct honest-empty, and nothing else:
| State | Meaning | Render |
|---|---|---|
read + items |
headlines arrived | title/source/age/link-out, text only |
read + [] |
vendor answered, sparse coverage | "No recent items for X." — content, not failure |
not-covered |
no mapping row | "No news source covers X." — no retry, no request |
unreadable |
gateway/vendor did not answer | sentence + Retry — never an empty feed |
not-configured |
module off, no gateway, or an image without the route (404) | NOTHING renders |
Layout: the feed is ranked, not listed¶
The first pass rendered every item as an equally-weighted underlined link. On a real ETC feed that put a one-hour report and a three-month price-prediction piece at identical visual weight, made the card a wall of teal, and gave the reader nothing but the age string to tell them apart — on a sheet whose actual job is Trade and Transfer. The ranking now lives in size, weight and spacing:
- One featured item — the NEWEST by published date, larger and unadorned, with source and age on one quiet line. Ordering is ours and comes from the dates alone: there is no ranking signal here that the vendor did not give us, and none we invented.
- Compact rows beneath it — two-line clamp, hairline dividers,
--text-primarytitles that take their underline on hover and focus. The link affordance is structural (each row's whole content is the link), so nothing here is small text in a brand hue and spec 090's rule (1) never applies. - Recency headings (Today / This week / Earlier) emitted only where the bucket CHANGES, seeded by the featured item's bucket — so a feed that all landed today renders no headings at all, and one that spans months visibly ends instead of running on.
- First paint is capped at the featured item plus three; the tail sits behind ONE disclosure whose
collapsed rows are unmounted, not hidden (a control claiming
aria-expanded="false"over rows still in the tab order is claiming something untrue — the spec-081 drawer lesson). Its label reports what is actually in the tail:Earlier · Nonly when every hidden item really is older than a week, otherwiseShow N older.
All of it is presentation over the same reading. Nothing is dropped, no item is summarized or re-titled, and every row still carries its source and its age — including inside the disclosure. Ages and buckets are both measured from the instant the feed landed, so the card reads one clock.
Items are text only: the normalizer (services/relay-gateway/src/news/normalize.js) forwards
id/title/url/source{name,slug}/publishedAt/sentiment and drops image, icon, vendor-account
fields, and the raw sentiment_score; non-https: URLs and undated items are dropped whole (the
mandatory age display cannot be honest without a date). Ages are always shown.
Cache discipline¶
The vendor itself serves cache-control: max-age=300, so NEWS_CACHE_TTL_MS is clamped to
≥ 300 000 — a lower value buys load, not freshness (one boot log line on clamp, never a silent
lie). The cache is single-flight per slug (N concurrent viewers of one asset = 1 upstream
request, SC-005), serves stale marked stale: true up to 10× TTL, and beyond that answers
unreadable — never stale-as-live.
The assistant tool¶
Exactly one public tool, get_token_news (@fairwins/assistant-contract, MCP snapshot
re-vendored and parity-gated). Tool-pull only: news is never in a prompt and never pre-loaded
— the spec-104 injection posture, because headline text is counterparty-authored content. The
description carries the honest wording the model reads: third-party reported with attribution,
never advice; unreadable stated, never summarized from memory; empty means sparse coverage. The
in-app loop resolves slug from the mapping before the call; MCP/external callers pass it
explicitly. No navigate, no build_intent — a news result never makes the app do anything.
What deliberately does not exist¶
- No datastore, no graph layer, no member-keyed interest record — the parked
retrieval/graph-context layer is issue #1513 (see
docs/research/alphaday-news-agent-context-evaluation.md); do not reintroduce it here. - No key, no credential — the vendor API is keyless (nothing for spec 097 to hold).
- No FeeRouter service, no payee — news carries no fee; FinOps entry
alphaday-news-api(modelled, $0 free tier) exists so the dependency is visible, not because money moves.
Operations¶
Two switches, and both must be on for a member to see anything: the news tenant feature
(on for fairwins) and NEWS_ENABLED on the gateway (set true in
infra/vm/gateway/docker-compose.yml). Either one off is honest absence — the surface renders
nothing rather than an error. Quotas ride the standard gateway knobs (NEWS_QUOTA_PER_IP /
NEWS_QUOTA_GLOBAL / NEWS_QUOTA_WINDOW_MS), and NEWS_KILLSWITCH=true answers
503 news_killed ahead of everything else.
The flag is not the whole enablement¶
A gateway image that predates spec 109 has no /v1/news/* route at all, so NEWS_ENABLED
does nothing there — the same trap the PERPS_ENABLED note in that compose file records. The
enablement is therefore two steps, in this order:
- Build and publish a relay-gateway image from a commit containing
services/relay-gateway/src/news/, and pin that tag ininfra/vm/gateway/docker-compose.yml. Verify before pinning: the image's/statusmust carry anewsblock. (The gateway image is an operator build — CI only builds and boots it as a check, andcloudbuild.yamlbuilds the SPA, not this image.) - Deploy the stack (
systemctl restart fairwins-stack@gateway— never a single container; the sidecars share the gateway's network namespace).
The SPA and the gateway deploy independently, so the frontend card can be live while the module
is not. That window is deliberately silent: newsClient.js reads a 404 as not-configured,
because the module mounts unconditionally and so an image carrying it answers 503 when switched
off and never 404s. Without that branch every asset sheet and trade pair would show "The news
feed could not be read right now." plus a Retry, for a surface that simply has not shipped yet.
See specs/109-token-news/ (research.md carries the probe evidence) and
contracts/gateway-news-api.md for the route contract.