Ingestion pipeline: fetch, cluster, synthesize
How raw source coverage becomes a story in the feed, and which stages are real today.
The whole ingestion path — fetch, dedupe, enrichment, clustering, synthesis, caching — lives in one file: src/lib/services/rss-aggregation.ts. It runs inline, synchronously, inside whichever request triggered it. There is no worker, no job table, and no intermediate persistence between stages. The public entry point is listAggregatedStories() (src/lib/services/rss-aggregation.ts:3830), which returns cache.stories from loadFeedCache().
The pipeline never writes an article. Its output is a source story — headline, synthesized body, sources, confidence — plus an editorial draft whose headline and body are placeholder text. A human must run compose before any publishable article exists. Anything that says otherwise is wrong.
Two paths, one live
| Path | Status | Entry point |
|---|---|---|
| Inline aggregation | Authoritative, always on | listAggregatedStories(), rss-aggregation.ts:3830 |
| Vercel Queues fan-out | Wired, flag-gated off | src/lib/platform/queues-adapter.ts, consumers under src/app/api/queues/rss/* |
The rssQueuePipeline flag defaults to false (src/lib/contracts/feature-flags.ts:52) and is flipped by hand at /admin/flags, where it renders as "RSS queue pipeline" with the description "Routes RSS aggregation through the Vercel Queues fan-out instead of the inline path. Off by default; the inline aggregation remains authoritative until this is enabled." Even with it on, only the rss.ingest consumer does work, and that work is the same inline aggregation. See the queue subsystem below.
What triggers a build
| Trigger | Scope built | Notes |
|---|---|---|
GET /api/stories (feed load) | The caller's tenant scope | Cold or empty cache builds synchronously |
GET /api/stories?forceRefresh=true | The caller's tenant scope | The feed's "Refresh sources" button |
Cron GET /api/admin/rss/refresh every 10 min | Every completed tenant scope, serially | The authoritative scheduled ingestion |
POST /api/admin/rss/refresh | Global catalog scope | Curl/runbook only — no UI calls it |
Loading /admin/sources or /admin/queues | Global catalog scope | See the warning below |
rss.ingest queue consumer | Global catalog scope | Only when rssQueuePipeline is on |
/admin/sources and /admin/queues call listSourceFetchHealth() and listStoryFeedItems() with no scope (src/lib/platform/admin/dashboards.ts:96-100, 149-154). That routes into loadFeedCache() for the global scope, so opening an admin page on a cold instance can trigger a full fetch, cluster and AI synthesis run — and the AI spend that goes with it.
The cron handler (src/app/api/admin/rss/refresh/route.ts, maxDuration = 300) does three things in order: publish an ingest message if the queue flag is on, then always run ingestAllTenantScopedFeeds(), then run evaluateInstantAlertRulesForAllTenants(). Tenant ingestion is deliberately not gated on the queue publish — the in-code comment states that the rss.ingest consumer never touches tenant scopes, so gating it would reopen the staleness bug it was written to close.
ingestAllTenantScopedFeeds() (src/lib/platform/local-platform-data.ts:1203) enumerates completed newsroom tenant scopes, processes them serially under TENANT_FEED_INGESTION_TIME_BUDGET_MS (ERMIS_TENANT_INGESTION_TIME_BUDGET_MS, default 240000), and starts at a rotating offset of floor(now / 10 min) % n so the tail is not permanently starved. Each scope is refreshed with { forceRefresh: true, bootstrapRefresh: 'never' } and is failure-isolated. It returns { tenantScopes, refreshed, failed, skippedTimeBudget, elapsedMs }.
Stage 1 — which sources are in scope
A global build reads listSourceCatalogFromRepository({ ingestEnabledOnly: true }) (rss-aggregation.ts:3571), i.e. rows that are both active and ingest_enabled. In the bundled catalog that is 13 of 1,427 entries.
A tenant build resolves its own list in resolveTenantScopedSourceCatalog (src/lib/platform/local-platform-data.ts:963):
selectedSourceIds; fall back to the tenant's monitored source ids.source_catalog_entries row has ingest_enabled = false. This is the operator kill switch, and it overrides the tenant's selection. Custom sources have no catalog row, so they default to enabled.Catalog mechanics, the active versus ingest_enabled distinction, and the manual sync route are covered in Source catalog operations.
Stage 2 — fetch
fetchFeed (rss-aggregation.ts:3290) fetches each feed through fetchPublicNetworkResource (rss-aggregation.ts:66), which validates the target against the shared public-network blocklist before connecting, follows redirects with redirect: 'manual', and re-validates every hop up to MAX_PUBLIC_FETCH_REDIRECTS = 4. DNS resolution is skipped when NODE_ENV === 'test'.
This code calls undici's own fetch, not Node's global fetch. The comment at rss-aggregation.ts:79-81 records why: a pinned dispatcher built from the installed undici (v8) is rejected by Node's bundled undici (v7) with UND_ERR_INVALID_ARG, which surfaced as fetch failed on every source and emptied the feed. If you change the fetch call, keep the undici import.
- Request headers:
User-Agent: ErmisAI/1.0 RSS Aggregator, plus RSS/Atom/XMLAccept. - Timeout:
FEED_REQUEST_TIMEOUT_MS, default 8000 ms, viaAbortController. - Concurrency:
FEED_FETCH_CONCURRENCY, default 12.
Every fetch writes a status back to Postgres via updateSourceFetchStatus — ok, failed, or empty. Sources whose id starts with custom- are skipped: they have no source_catalog_entries row, so the update would match nothing (src/lib/db/source-catalog-repository.ts:690-694). Custom-source health therefore exists only inside the tenant's scoped feed cache, which is why the admin "Source health" table never lists them.
An empty parse is diagnosed by getEmptyFeedParseMessage (rss-aggregation.ts:1461) into one of three strings, and that string is what the admin table shows as the last error:
Feed response looked like HTML and did not contain RSS or Atom itemsFeed response content-type {ct} did not contain RSS or Atom itemsFeed parsed with no RSS item or Atom entry elements
Failed and empty results are recorded with errorRate24h: 100.
Stage 3 — parse and normalize
The parser is regex-based, not an XML library. parseFeedItems (rss-aggregation.ts:1419) matches <item>…</item> and <entry>…</entry> blocks. Body preference is content:encoded / content, falling back to description / summary. Categories come from <category> and <dc:subject>.
- Per-source cap:
MAX_ITEMS_PER_SOURCE, default 30. - URL normalization strips
utm_*,fbclid,gclidand the hash fragment. - The article id is a deterministic hash of
sourceId|url|publishedAt|headline, prefixedstory-(createArticleFromFeedItem,rss-aggregation.ts:1577). breakingis a heuristic: the headline contains "breaking" or "live", or the item is 90 minutes old or less (toBreaking,rss-aggregation.ts:1562).- Dedupe is by normalized URL, ties broken by longer body then newer
publishedAt(dedupeArticles,rss-aggregation.ts:1756).
Stage 4 — article enrichment
Most feeds ship teasers. isLikelyTeaserBody (rss-aggregation.ts:1598) treats a body as a teaser when it is empty, equals the headline, is under 55 words, or matches teaserMarkerRegex (rss-aggregation.ts:559):
/read more|continue reading|click here|περισσότερα|συνέχεια|full story|learn more|view on site/iTeasers are re-fetched from the article URL through the same SSRF-guarded path, with User-Agent: ErmisAI/1.0 Article Fetcher and ARTICLE_REQUEST_TIMEOUT_MS (default 5000 ms). Extraction prefers <article> / <main> blocks of 120 words or more, else joined <p> blocks of 10 words or more each; the result is discarded below 70 words. Successful bodies are cached in a process-global map for ARTICLE_BODY_CACHE_TTL_MS (default 45 minutes).
Enrichment is capped at MAX_ARTICLE_ENRICHMENT_PER_REFRESH (default 48) with ARTICLE_ENRICHMENT_CONCURRENCY (default 6). When it succeeds, bodyOrigin flips from 'feed' to 'article-fetch'.
Stage 5 — clustering
Articles are sorted newest-first and truncated to MAX_CLUSTER_INPUT_ARTICLES (default 48) before clustering.
Grouping is always heuristic. clusterArticlesHeuristically (rss-aggregation.ts:2043) does greedy grouping on articleSimilarity (rss-aggregation.ts:2013):
headlineSimilarity * 0.75 + bodySimilarity * 0.25 + (categoryOverlap ? 0.08 : 0)Both similarities are Jaccard over tokenized text; the body comparison uses only the first 320 characters. The join threshold is HEURISTIC_CLUSTER_SIMILARITY_THRESHOLD = 0.24. Groups are seeded in source-tier order — wire 1 > tier1 0.9 > tier2 0.72 > tier3 0.55 — then by recency.
The cluster id is derived from the earliest member article, not the whole member set (rss-aggregation.ts:2092-2108). Hashing the full set minted a new story id every time coverage grew by one article, so the same evolving event re-entered the feed as a near-duplicate draft on every refresh. The cluster id is the story id — synthesizeClusterStory returns id: input.cluster.id.
The AI pass refines metadata; it never decides membership. clusterArticlesWithAi (rss-aggregation.ts:2664) sends only clusters with two or more articles, and the model returns exactly eventSummary, category, breaking, and one uniqueAngle per article (aiClusterRefinementSchema, rss-aggregation.ts:492). Article-to-cluster assignment is entirely the heuristic's.
Two details worth knowing:
maxOutputTokensfor refinement scales with cluster size:min(8192, max(2048, 256 + n × 96))(resolveClusterRefinementMaxOutputTokens,rss-aggregation.ts:212). A flat 2048 cap structurally truncated the JSON at roughly 35 articles, which clusters reach during major breaking events.- The refinement prompt instructs the model to write
eventSummaryandcategoryin the dominant language of the source material (rss-aggregation.ts:2576). This is the one stage that does not follow the newsroom's content locale. The returned category is then normalized through the canonical registry (src/lib/i18n/categories.ts), falling back to keyword inference and finallyworld.
Clusters are prioritized by member count, then newest member, and truncated to MAX_CLUSTERS_PER_REFRESH (default 12) before synthesis.
Stage 6 — synthesis
synthesizeClusterWithAi (rss-aggregation.ts:2878) emits a structured object validated by aiSynthesisSchema (rss-aggregation.ts:522): headline (5–300 chars), body (100–15000 chars), eventSummary, uniqueAngles (max 8), conflicts (max 8), and sensitive.
Output language is a setting, not the source language. The prompt says: "The output language is the newsroom's configured content language (a setting), NOT the source language: translate and rewrite source material into {language} wherever needed" (rss-aggregation.ts:2846), and repeats it in the instructions (rss-aggregation.ts:2981). The stale comment at rss-aggregation.ts:2133 claiming "Feed synthesis stays in the source language — it never translates" contradicts the code directly below it. Do not trust it.
Inputs are bounded hard: only the top AI_SYNTHESIS_MAX_SOURCE_ARTICLES = 8 articles by tier and recency are sent, each body truncated to AI_SYNTHESIS_SOURCE_BODY_CHAR_LIMIT = 700 characters.
Voice rules are baked into the prompt (rss-aggregation.ts:2979-2991): first-party original reporting, the event as the subject rather than an outlet's coverage of it, explicit forbidden phrasings ("Al Jazeera published…", "the BBC reported…", "according to the description"), inline attribution only for specific or contested facts, and a length constraint of 220–900 words with "a tight 400-600 word article is ideal".
Quality gate. If the returned body is under MIN_SYNTHESIZED_BODY_WORDS = 220 words, one bounded regeneration runs with explicit revision feedback naming the actual word count; the revision is kept only if it is longer (rss-aggregation.ts:3038-3058). A failure of the revision pass is swallowed and the short draft is kept.
Synthesis runs across clusters at FEED_SYNTHESIS_CONCURRENCY (default 3).
A single cluster's synthesis failure does not fail the build — that cluster is skipped and retried next refresh. But if every cluster failed and there was at least one cluster, the first error is rethrown on purpose (rss-aggregation.ts:3630-3671), so the stale-cache fallback keeps serving the previous build instead of overwriting it with an empty feed.
Stage 7 — confidence and pipeline status
calculateConfidence (rss-aggregation.ts:2796) is a weighted sum clamped to 40–99:
sourceScore × 30 + averageTierWeight × 25 + agreementScore × 25 + recencyScore × 20sourceScore=min(1, uniqueSources / 5).averageTierWeightuses the tier weights above.agreementScoreis the mean pairwise Jaccard of headlines, floored at 0.25 (0.45 for a single article). It is lexical headline overlap, not fact comparison.recencyScoreis a step function on the newest member: ≤ 30 min → 1, ≤ 2 h → 0.86, ≤ 8 h → 0.68, ≤ 24 h → 0.52, otherwise 0.4. A missing timestamp scores 0.35.
resolveStoryPhase (rss-aggregation.ts:3107) then assigns the pipeline status:
if (input.confidence >= STORY_AUTOPUBLISH_CONFIDENCE && !input.sensitive) {
return 'published' as const
}
return 'review' as constSTORY_AUTOPUBLISH_CONFIDENCE is 85. The pipeline only ever emits review or published. It never emits collecting, clustering, or synthesizing — those values exist in the contract enum but no code path produces them.
sensitive comes from the model. In the heuristic path it comes from sensitiveContentRegex (rss-aggregation.ts:2121), which covers English and Greek terms only.
Stage 8 — materialization into editorial drafts
listTenantScopedArticleDrafts (src/lib/platform/drafts/tenant-draft-store.ts:239) reads the tenant's stories and its persisted editorial_story_drafts rows, then runs synchronizeDraftCollectionWithStories, creating a draft for every story id it has not seen.
A newly materialized draft's headline and body are placeholders, not the synthesized article. buildEditorialDraftBody (src/lib/platform/local-platform-data.ts:434) always writes INITIAL_DRAFT_PLACEHOLDER_BODY plus a localized source line. buildEditorialDraftHeadline (:420) keeps the synthesized headline only when it is not mostly Latin script, otherwise substituting the placeholder.
The English placeholder (src/lib/stories/draft-state.ts:4-11):
- headline —
Draft in composition - body —
Initial article composition is pending in English.\n\nUse the compose flow to generate the first publishable draft. - source line —
The initial article will be composed from {n} sources once the first compose action runs.
Localized variants exist for all ten content locales in the same file.
The synthesized text is not lost: it is preserved in the draft's story_snapshot jsonb column and is what the compose flow consumes through buildComposeStoryContextBody (local-platform-data.ts:441-468), which truncates the source body at 8000 characters.
Other materialization behaviour:
- Initial editorial state (
resolveInitialEditorialState,local-platform-data.ts:490): pipelinereview→in_reviewwithsubmittedForReviewAtset tostory.updatedAt; pipelinepublished→draft. A new draft never starts at editorialpublished;publishedAtis owned exclusively by the human approve transition. - Initial priority:
highwhen the story isbreakingorsensitive, otherwisestandard. - Claim provenance is synthesized deterministically from paragraphs and source attributions, max 8 claims (
buildDraftClaimProvenance,local-platform-data.ts:551). - Persistence:
editorial_story_drafts, primary key(tenant_scope_id, story_id), with aversioncolumn for optimistic concurrency and CHECK constraints enforcingin_review ⇒ submitted_for_review_at,published ⇒ published_at,rejected ⇒ reviewed_at(src/lib/db/schema.ts:530-615). Sync writes are compare-and-set guarded in passive mode: a version conflict means a human edit landed, so the sync copy is skipped rather than clobbering it. - New stories fire
onNewStoriesSynchronized→evaluateAlertRulesForTenant, scheduled withrunAfterResponseso the feed request never blocks on alert evaluation.
What the feed derives from the draft
mapDraftToFeedItem (src/lib/platform/feed/feed-lifecycle.ts:60) calls deriveStoryWorkflowState (src/lib/platform/feed/publishability.ts:42):
articleReady = !doesDraftRequireInitialCompose({ headline, body })— false while the placeholder is present.- When
articleReadyis false, any phase outside{collecting, clustering, synthesizing}collapses tosynthesizing(resolvePreEditorialPhase,publishability.ts:24-26). Since the pipeline only emitsrevieworpublished, a freshly materialized draft always shows the "Ready to compose" chip. deliverable = articleReady && editorialState === 'published';reviewQueueEligible = articleReady && editorialState === 'in_review'.
The confusing consequence, and it is the normal case: a story with pipeline status review shows an "In review" editorial badge next to a "Ready to compose" pipeline chip, and does not appear on the editorial board, because board eligibility requires articleReady.
Caching, scoping and locking
This is where most operational behaviour lives.
Scope key. getFeedScopeKey (rss-aggregation.ts:3438) returns global when no source catalog is passed, sources:<comma-joined ids> for a tenant, sources:none for an explicit empty catalog, plus a |locale:<code> suffix when the content locale is not the platform default. Two tenants with the same source set and the same content locale share one cache entry and therefore one AI build.
Three layers, checked in order:
- Per-instance globals (
globalThis.__ermisStoryPipeline*,rss-aggregation.ts:598-607). - A Postgres
platform_staterow keyedplatform:story-feed-cache:v1:<encoded scope>withexpires_at. - A rebuild.
TTL is FEED_CACHE_TTL_MS (default 3 minutes); the persisted row's retention is FEED_CACHE_PERSIST_RETENTION_MS (default 3 hours). Persisted caches are zod- and predicate-validated on read, and any malformed record is discarded wholesale (coercePersistedFeedCache, rss-aggregation.ts:699). A Postgres error disables the persisted layer process-wide for the rest of that instance's life (disableFeedCacheDb). Empty caches are never persisted.
Serve-stale-then-refresh versus synchronous cold build. A warm, non-empty cache is returned immediately and a background refresh is kicked when it is stale. A cold, empty, or forced path builds synchronously and awaits. The comment at rss-aggregation.ts:3823-3827 records why: a fire-and-forget bootstrap build never converged on serverless, because the instance freezes after the HTTP response — it fetched sources but never synthesized or persisted, leaving cold tenants stuck.
Throttles (per instance): FEED_AUTO_REFRESH_MIN_INTERVAL_MS 45 s and FEED_FORCE_REFRESH_MIN_INTERVAL_MS 20 s. The throttle is honoured only when there is real content to serve, so a cold tenant always converges.
Cross-instance build lock. Redis SET NX PX on ermis:feed:build-lock:<scope> with FEED_BUILD_LOCK_TTL_MS = 7 minutes, deliberately longer than the 300 s function budget.
The build lock fails open. No Redis, or any Redis error, returns a bypass token and the build proceeds unlocked. A losing instance serves existing content only if it has some; a cold instance with nothing builds anyway. Correct output, duplicated AI spend across warm instances.
In-flight promise dedupe has a staleness escape hatch: a load promise older than FEED_BUILD_PROMISE_STALE_MS = 120 s is treated as dead (a frozen serverless instance) and a rebuild starts.
On build failure with allowCacheFallback, the previous cache is re-served without re-stamping expiresAt (rss-aggregation.ts:3738-3756) — deliberately, so a repeatedly failing build cannot indefinitely extend a cache that claims to be fresh.
Strict mode and the fallback matrix
isStrictAiPipelineMode() is ERMIS_AI_PIPELINE_STRICT_MODE === 'true', default false. shouldAllowAiPipelineFallback() is !strictMode && NODE_ENV !== 'production'. shouldAllowFeedCacheFallback() is !strictMode (rss-aggregation.ts:630-650).
| Context | Heuristic synthesis fallback | Stale-cache fallback |
|---|---|---|
| dev or test, strict off | yes | yes |
| production, strict off | no | yes |
| strict on, any environment | no | no — the build failure throws |
In production there is no heuristic synthesis safety net. An AI outage produces skipped clusters and a stale feed, not lower-quality stories. Heuristic clustering still always runs — it is the input to AI refinement, not a fallback for it — and heuristic body padding still runs through ensureSubstantiveSynthesis whenever strict mode is off, including production.
One consequence of that padding: ensureSubstantiveSynthesis (rss-aggregation.ts:2364-2392) can replace a valid-but-short AI body with heuristic prose while keeping the AI headline and the synthesisModel id, so a story labelled with a model id may contain template text. The heuristic narrative templates ship in English and Greek only and pick whichever script dominates the source material; they do not honour the tenant's content locale.
isAiPipelineConfigured() (rss-aggregation.ts:1991) returns false when ERMIS_DISABLE_AI_PIPELINE === 'true', and otherwise checks hasAzureLanguageModelConfiguration() in azure mode or hasGatewayLanguageModelConfiguration() in gateway mode.
How the pipeline calls models
Per-stage execution policies (src/lib/ai/language-model-policies.ts:132-170):
| Stage | temperature | maxOutputTokens | maxRetries | timeout |
|---|---|---|---|---|
cluster | 0.1 | 2048 | 0 | 120 000 ms |
synthesis | 0.2 | 8192 | 0 | 120 000 ms |
maxRetries: 0 is set on every generateText call, so the AI SDK's own retry is off and the pipeline's ladder is the only retry mechanism.
Two distinct fallback mechanisms exist and they are easy to confuse.
- The gateway in-request chain (
stageFallbackChain,language-model-policies.ts:241-254) is executed by the Vercel AI Gateway inside a single request on 5xx, rate limit, or timeout:cluster: gemini-3-flash → claude-haiku-4.5 → grok-4-fast-non-reasoning;synthesis: claude-sonnet-4.6 → gemini-3-flash → grok-4-fast-non-reasoning. - The application-level ladder (
pipelineStageRetryModelIds,language-model-policies.ts:226-229) runs when a whole attempt fails:cluster: openai/gpt-4.1-nano → anthropic/claude-haiku-4.5 → anthropic/claude-sonnet-4.6;synthesis: openai/gpt-4.1-nano → anthropic/claude-sonnet-4.6 → anthropic/claude-opus-4.6.
Attempt order is [primary, configuredPrimary, ...retryIds] deduped and truncated to MAX_CLUSTER_AI_ATTEMPTS / MAX_SYNTHESIS_AI_ATTEMPTS, both 3, padded by repeating the last model when the ladder is short. resolveStagePrimaryModelId (rss-aggregation.ts:1165) reads the diagnostics snapshot: if the last recorded failure was on this stage with the configured model, attempt one starts on a different ladder model. In azure provider mode the app-level ladder is empty, so all three attempts reuse the configured Azure model.
Backoff between attempts is min((i + 1) × 750 ms, 3000 ms). Error classes configuration, authentication, authorization, billing, and quota short-circuit the ladder immediately (rss-aggregation.ts:1094-1104); content-filter, context-limit, and schema failures deliberately do not, because a different model may succeed.
Pipeline invocations run under tenantScopeId: 'platform:shared', actorType: 'system', billingScope: 'platform', billable: false, on surfaces pipeline_cluster and pipeline_synthesis. They are platform cost, not tenant cost — but assertAiInvocationAllowed still runs first, and the aiPaused kill switch and the platform spend cap hard-block them even when per-tenant guardrails are disabled. See AI usage accounting, guardrails and metering.
Telemetry spans are enabled only in production, with recordInputs: false and recordOutputs: false, so source material and generated bodies never enter spans.
Cluster and synthesis prompts are not versioned and do not live in src/lib/ai/prompts/. They are inline string arrays in rss-aggregation.ts (cluster at :2569, synthesis at :2974). Only story-compose and story-refinement carry version maps and hashes — see AI runtime.
Diagnostics
getNewsPipelineDiagnosticsSnapshot() (rss-aggregation.ts:1041) exposes status (idle | building | healthy | error), strictMode, aiConfigured, build timestamps and duration, lastBuildArticleCount, lastBuildClusterCount, lastBuildStoryCount, and a lastError object carrying stage, operation, message, model id and provider id. It is served by GET /api/admin/ai/diagnostics and rendered in the "News pipeline diagnostics" card on /admin/ai (super_admin only).
Two limits make this less useful than it looks:
- Only
globalscope builds are recorded. Tenant-scope builds — which is what the cron actually runs — are invisible here. - The snapshot lives in a process global, so it is per-instance and not shared across lambdas.
The queue subsystem: what is actually wired
Four topics are declared in src/lib/platform/queues-adapter.ts:28-33 — rss.ingest, rss.enrich, rss.cluster, rss.synthesize — and bound to consumer routes by experimentalTriggers of type queue/v2beta in vercel.json. Delivery is push-based; the app never polls.
| Route | maxDuration | visibility | maxAttempts | Behaviour |
|---|---|---|---|---|
src/app/api/queues/rss/ingest/route.ts | 300 | 360 | 5 | Real. Re-checks the flag, logs [queues:rss.ingest] running aggregation refresh batch=<id>, then calls listAggregatedStories({ forceRefresh: true }) — global scope only. |
src/app/api/queues/rss/enrich/route.ts | 120 | 120 | 5 | Throws rss.enrich consumer is not implemented; do not enable a producer for this topic |
src/app/api/queues/rss/cluster/route.ts | 300 | 300 | 3 | Throws the equivalent message |
src/app/api/queues/rss/synthesize/route.ts | 300 | 300 | 3 | Throws the equivalent message |
The ingest visibility timeout is 360 s on purpose, strictly greater than the 300 s maxDuration: an equal window redelivers a message whose handler was killed at exactly 300 s, "stacking up to maxAttempts whole-catalog AI builds from one message" (ingest/route.ts:34-38).
Be blunt about the rest:
rss.ingestis not a fan-out.publishRssAggregationRefreshpublishes exactly one message per refresh withsourceId: 'all-sources'andfeedUrl: ''. Cross-source clustering makes aggregation a whole-catalog operation, so per-source and per-stage staging is explicitly reserved for a future refactor backed by a persistence layer.- Enabling the flag does not offload tenant ingestion. The consumer refreshes the
globalcatalog scope only. Tenant scopes stay on the cron'singestAllTenantScopedFeeds. /admin/queuesshows fabricated metrics.listQueueHealth(src/lib/platform/admin/dashboards.ts:149-196) derives four synthetic rows from the global draft feed and the cross-tenant review queue, with several fields hardcoded to0and the ingest, cluster and synthesis cards all sharing onelagSeconds(the age of the newest story), while thereview-queuecard hardcodeslagSeconds: 0along with its retries and dead-letter counts. Nothing in the codebase reads Vercel Queue depth, retries, or the dead-letter topics.- There is no platform DLQ. On the final attempt,
createRssConsumerpublishes a summary to a sibling<topic>-dlqtopic, awaits it, acks, and swallows the error. Nothing reads those topics.
Messages carry an HMAC-SHA256 signature over a canonical JSON of every non-signature field sorted by key, keyed by ERMIS_QUEUE_SIGNING_SECRET. This exists because @vercel/queue's handleCallback performs no inbound verification and the consumer routes are allow-listed past Clerk in src/proxy.ts — without the HMAC, an anonymous POST to /api/queues/rss/ingest would trigger a full AI aggregation run. Verification is timingSafeEqual and fails closed: no secret configured means nothing verifies, so nothing is processed. Publishing without the secret throws rather than emitting a message that would be rejected.
Turning on rssQueuePipeline without setting ERMIS_QUEUE_SIGNING_SECRET breaks the cron. publishRssAggregationRefresh throws inside the cron handler, runGuardedCronHandler turns that into a 500, and tenant ingestion never runs for that tick. The secret is documented only in docs/ops/launch-checklist.md — it is missing from the README env reference.
More on scheduling and the operator view in Scheduled jobs and the queue subsystem.
Debugging an empty or stale feed
| Symptom | Likely cause | Where to look |
|---|---|---|
| Feed empty for one tenant, fine for others | The tenant's scope resolved to zero sources — every selected id has ingest_enabled = false, or the profile has none | resolveTenantScopedSourceCatalog, local-platform-data.ts:963 |
| Every source shows "empty parse" | Feeds returning HTML, or the regex parser found no <item> / <entry> blocks | getEmptyFeedParseMessage string in source_catalog_entries.last_error_message |
fetch failed on every source | undici major mismatch — the pinned dispatcher was passed to Node's global fetch | rss-aggregation.ts:79-81 |
GET /api/stories returns 503 stories_feed_unavailable | The 95 s internal timeout fired, usually on a cold synchronous whole-catalog build | STORIES_ROUTE_TIMEOUT_MS, src/app/api/stories/route.ts:23 |
| Feed stale everywhere, nothing scheduled ran | CRON_SECRET unset or wrong, so every cron 401s | isAuthorizedCronRequest, src/lib/api/cron-auth.ts:46-65 |
| Some tenants fresh, others hours behind | Serial ingestion hit the 240 s budget; check skippedTimeBudget in the cron response | ingestAllTenantScopedFeeds, local-platform-data.ts:1203 |
| Feed stops updating but does not error | Every cluster's synthesis failed, so the previous cache is re-served | lastError in GET /api/admin/ai/diagnostics (global scope only) |
| AI spend higher than expected | The Redis build lock failed open, so multiple instances built the same scope | acquireFeedBuildLock, rss-aggregation.ts:870 |
| Cold instances always rebuild | A Postgres error latched the persisted cache off process-wide | disableFeedCacheDb stderr line |
isAuthorizedCronRequest compares Authorization: Bearer ${CRON_SECRET} with timingSafeEqual and returns false when CRON_SECRET is unset, so a missing environment variable can never open the endpoint — it can only silence the schedule.
Tuning knobs
Every value below goes through readBoundedRuntimeNumber (rss-aggregation.ts:107-126): out-of-range values are clamped, and an unparseable value silently falls back to the default.
| Environment variable | Default | Min | Max |
|---|---|---|---|
ERMIS_FEED_CACHE_TTL_MS | 180000 (3 min) | 30000 | 1800000 |
ERMIS_FEED_CACHE_PERSIST_RETENTION_MS | 10800000 (3 h) | = cache TTL | 86400000 |
ERMIS_FEED_REQUEST_TIMEOUT_MS | 8000 | 2000 | 60000 |
ERMIS_FEED_FETCH_CONCURRENCY | 12 | 1 | 48 |
ERMIS_ARTICLE_REQUEST_TIMEOUT_MS | 5000 | 1500 | 30000 |
ERMIS_FEED_MAX_ITEMS_PER_SOURCE | 30 | 5 | 100 |
ERMIS_FEED_MAX_ARTICLE_ENRICHMENT_PER_REFRESH | 48 | 8 | 200 |
ERMIS_ARTICLE_ENRICHMENT_CONCURRENCY | 6 | 1 | 24 |
ERMIS_ARTICLE_BODY_CACHE_TTL_MS | 2700000 (45 min) | 60000 | 43200000 |
ERMIS_FEED_MAX_CLUSTER_INPUT_ARTICLES | 48 | 8 | 200 |
ERMIS_FEED_MAX_CLUSTERS_PER_REFRESH | 12 | 2 | 60 |
ERMIS_FEED_SYNTHESIS_CONCURRENCY | 3 | 1 | 12 |
ERMIS_EMPTY_FEED_CACHE_TTL_MS | 25000 | 5000 | = cache TTL |
ERMIS_FEED_AUTO_REFRESH_MIN_INTERVAL_MS | 45000 | 0 | 600000 |
ERMIS_FEED_FORCE_REFRESH_MIN_INTERVAL_MS | 20000 | 0 | 600000 |
Non-numeric switches read by this subsystem. CRON_SECRET, ERMIS_CONTENT_LOCALE and ERMIS_TENANT_INGESTION_TIME_BUDGET_MS are documented in the README (lines 65-67); the strict-mode, disable-pipeline, disable-persisted-cache and queue-signing switches are not documented anywhere except this page and docs/ops/launch-checklist.md:
| Variable | In README? | Effect |
|---|---|---|
ERMIS_AI_PIPELINE_STRICT_MODE | No | true disables both the heuristic fallback and the stale-cache fallback |
ERMIS_DISABLE_AI_PIPELINE | No | true makes isAiPipelineConfigured() return false |
ERMIS_DISABLE_PERSISTED_FEED_CACHE | No | Skips the Postgres cache layer |
ERMIS_QUEUE_SIGNING_SECRET | No | Required to publish or accept any queue message |
ERMIS_TENANT_INGESTION_TIME_BUDGET_MS | Yes | Serial tenant-ingestion budget, default 240000 |
ERMIS_CONTENT_LOCALE | Yes | Platform default synthesis output locale; a tenant's contentLocale overrides it |
CRON_SECRET | Yes | Bearer token for every cron route; unset means every cron 401s |
Constants that are not configurable at all: MIN_SYNTHESIZED_BODY_WORDS = 220, STORY_AUTOPUBLISH_CONFIDENCE = 85, HEURISTIC_CLUSTER_SIMILARITY_THRESHOLD = 0.24, AI_CLUSTER_SUMMARY_CHAR_LIMIT = 160, AI_SYNTHESIS_MAX_SOURCE_ARTICLES = 8, AI_SYNTHESIS_SOURCE_BODY_CHAR_LIMIT = 700, MAX_CLUSTER_AI_ATTEMPTS = MAX_SYNTHESIS_AI_ATTEMPTS = 3, FEED_BUILD_LOCK_TTL_MS = 7 min, FEED_BUILD_PROMISE_STALE_MS = 120 s, MAX_PUBLIC_FETCH_REDIRECTS = 4, FEED_CACHE_STORAGE_VERSION = 1.
Scheduled jobs and the queue subsystem
The five crons, CRON_SECRET, and the honest state of Vercel Queues.
AI runtime: providers, stages and prompts
Provider modes, stage policies, model resolution and prompt versioning.
Source catalog operations
The canonical catalog file, the manual sync route, active versus ingest-enabled.
AI usage accounting and guardrails
The ledger, the admission order, and the kill switches that stop the pipeline.
Pipeline phases and editorial states
What the labels on a feed card mean to a user.
Confidence, sources and conflicts
What the 40-99 score is built from, and what it does not prove.
