Skip to main content
Version: main

RSS / Atom

Build flag:

cargo build --release -p skardi-server --features rss
skardi query -e "..." # the CLI is a thin HTTP client; only the server needs the feature

The whole provider is gated behind the rss Cargo feature. Without it, a context declaring an rss source fails registration.

rss is a read-only Skardi data source that turns a subscription list — RSS 0.9x/1.0/2.0, Atom, JSON Feed — into two ordinary Arrow-backed DataFusion tables. One configured source is one subscription list; a single feed is a list of length one.

Everything downstream of the two tables is user-space SQL. The provider is a protocol adapter: it fetches, parses, normalizes, and reports health. History, chunking, embedding, and search compose out of primitives that already ship (INSERT … SELECT, chunk(), candle(), sqlite FTS/KNN) — see Pipeline examples.

Access is read-only. The subscription list is configuration, never SQL-mutable data: no statement can add, alter, or remove a subscription, and access_mode: read_write is rejected at registration. Agents manage subscriptions by editing ctx.yaml (or the OPML file) and reloading.


Catalog namespace​

An rss source registers as a catalog (hierarchy_level: catalog is required; anything else is rejected). The two tables live beneath the conventional main schema, mirroring the sqlite catalog convention:

.main.feeds     -- one row per subscription: fetch health
.main.items -- the live union of every subscription's window

A source named news is therefore queried as news.main.feeds / news.main.items. This document uses news throughout.

Registration performs no network I/O. The only I/O at registration is reading the opml: file, when one is configured. Nothing probes a feed, so startup cost is proportional to the length of the subscription list rather than to the availability of the hosts on it: fifty subscriptions do not wait on fifty upstreams to become queryable, and an unreachable host surfaces as that subscription's feeds.last_status instead of failing the whole source. Every HTTP request happens later, inside an items scan.

Surface version​

feeds and items are a versioned public interface. The current surface version is 1, and it is emitted in two places:

  • a tracing::info! line at registration (surface_version = 1), and
  • both tables' Arrow schema metadata, under the key skardi.rss.surface_version — so a client can read the version off a query result without access to the log.

The column set evolves additively under one version. Removing, renaming, or retyping a column, tightening nullability, repurposing an enum domain (last_status, window_status), or changing (feed, guid) identity or window semantics is a breaking change and bumps the integer.

What gets logged​

Response bodies are never logged. One debug line is emitted per feed per serve, carrying the source and subscription names, the subscribed feed URL, the outcome, the HTTP status, and byte/row/note counts — the body is described, never quoted. A degraded feed additionally emits a warn with its last_error.

The feed URL in those debug lines is the thing to know before turning debug on in an environment where logs go somewhere less trusted than the config does: a subscription URL can carry a private token in its query string, and at debug it is in the log. At info and above, no feed URL is emitted. One URL-shaped string can still appear there: a feed's name, which for an unnamed subscription defaults to its URL stripped of credentials, query, and fragment — the parts that can carry a secret — and names are exactly what info+ events carry.


Configuration​

type: rss sources carry a typed rss: block rather than the flat options: map — a nested subscription list cannot be expressed safely as HashMap<String, String>.

kind: context
metadata: { name: news-context, version: 1.0.0 }
spec:
data_sources:
- name: news
type: rss
hierarchy_level: catalog
rss:
feeds:
- url: https://blog.rust-lang.org/feed.xml
name: rust-blog # optional; defaults to the URL minus credentials/query
- url: https://this-week-in-rust.org/rss.xml
# or: opml: subscriptions.opml # mutually exclusive with feeds:
ttl_seconds: 900 # 0 = always live
max_concurrent: 6 # in-flight fetches for THIS source; not per-host, not per-process
request_timeout_seconds: 10
scan_timeout_seconds: 60
max_response_bytes: 5242880
user_agent: "skardi-rss/<version> (+https://github.com/SkardiLabs/skardi)"

A ready-to-run version of this file lives at docs/sample_data/rss_context.yaml.

Field reference​

FieldDefaultMeaning
feeds—Inline subscription list. Each entry has a url (must be http/https) and an optional name. Mutually exclusive with opml; exactly one of the two must be set.
opml—Path to an OPML subscription list. Read once, at registration. Mutually exclusive with feeds.
ttl_seconds900Per-feed cache TTL. 0 means always-live: every items scan revalidates every feed it visits. The only bound where zero is legal.
max_concurrent6Feeds of this source fetched concurrently. Not a per-host bound and not a per-process one — see Politeness for what it actually bounds.
request_timeout_seconds10Timeout for one feed HTTP request.
scan_timeout_seconds60Deadline for one whole scan across every subscribed feed.
max_response_bytes5242880 (5 MiB)Cap on one decoded response body, so a compressed payload cannot inflate past it.
user_agentskardi-rss/<crate version> (+https://github.com/SkardiLabs/skardi)Sent on every request.

Bounds other than ttl_seconds must be at least 1; user_agent must be non-blank. Unknown keys are rejected at both levels — a misspelled ttl_secondsss fails loudly with the offending field named, rather than being dropped and silently changing the config's meaning.

Two of the bounds also have upper ceilings, applied silently-but-loudly: the value is clamped and a warn line records both the configured and the effective number. Neither is a config error, so nothing fails to load.

FieldCeilingWhy
ttl_secondsone yearThe TTL becomes a Duration added to an Instant on every arm, and a large enough add can panic (std's own Instant docs give a macOS example). Longer than any meaningful feed TTL.
scan_timeout_secondsone hourThe deadline becomes an Instant the same way. A scan that has run for an hour has a different problem than its deadline.

Subscription names must be unique across the resolved list, comparing the effective name (an explicit name, else the URL). Two subscriptions may share a url under different names; pruning on that shared URL visits both.

OPML mode​

opml: names a file of <outline xmlUrl="…"> entries. Nesting is irrelevant — every outline carrying an xmlUrl becomes a subscription, in document order, and a grouping outline without one (a folder like <outline text="Tech">) is structure, not a feed, and is skipped. The subscription name is the outline's text attribute, else its title, else the URL.

The file is read on the registration path, not during config validation, and every check the inline form gets is re-applied to what it contains: non-emptiness, http/https scheme, name defaulting, and effective-name uniqueness.


Table schemas​

<name>.main.feeds​

One row per subscription, always — the table stays total over the subscription list, which is what makes the absence check meaningful. A feeds scan is a pure state read: it issues zero HTTP requests at any moment, including immediately after a failure.

ColumnArrow typeNullNotes
nameUtf8noConfigured name; defaults to the URL stripped of credentials, query, and fragment; unique.
urlUtf8noSubscription URL.
titleUtf8yesWire title, once fetched.
site_urlUtf8yesThe feed's HTML alternate.
descriptionUtf8yes
last_fetchTimestamp(ms, UTC)yesTime of the last attempt, success or not.
last_statusUtf8nonever | fresh | revalidated | stale-error | error
http_statusUInt16yesLast HTTP response code; NULL when no status was ever observed (e.g. a refused egress target).
last_errorUtf8yesFailure reason, bounded and body-free (see Reading last_error).
etagUtf8yesConditional-request validator.
last_modifiedUtf8yesConditional-request validator.
dialectUtf8yesParsed dialect (see Conformance).
dialect_declaredUtf8yesWhat the document claimed.
conformance_notesUtf8yesJSON array of note strings; [] means clean.
item_countUInt64yesEntries in the current window.

<name>.main.items​

The live union across subscriptions; identity is (feed, guid).

ColumnArrow typeNullNotes
feedUtf8noSubscription name; pushdown-prunable.
feed_urlUtf8noPushdown-prunable.
guidUtf8noStable item identity (see Field mapping).
titleUtf8yes
linkUtf8yes
authorUtf8yes
publishedTimestamp(ms, UTC)yes
updatedTimestamp(ms, UTC)yes
contentUtf8yesMarkdown, converted at extraction.
summaryUtf8yesMarkdown, same conversion.
categoriesList<Utf8>yes
enclosure_urlUtf8yesPodcast/media attachment.
enclosure_typeUtf8yesMIME type.
enclosure_lengthUInt64yesBytes.
positionUInt32noDocument order within the feed's window.
window_statusUtf8nofresh | revalidated | stale-error — the serving window's freshness.
extensions_jsonUtf8yesNon-core fields as JSON (see the boundary).

An in-place edit to an entry (same guid, new updated) simply reflects in the live window. Versioning is an archive concern, not a provider one.

window_status mirrors feeds.last_status restricted to the row-serving states. never and error produce zero rows and therefore cannot appear on a row at all — those feeds are visible only in feeds.


Freshness and caching​

Live reads are the default. Each feed independently falls into one of three tiers on an items scan:

TierWhat happenswindow_status / last_status
Within TTLServe the cached window. Zero network, and no politeness permit taken — there is no side effect to gate.whatever the last attempt recorded
TTL expired, 304 Not ModifiedHeader-only revalidation; the cache is re-armed and not reparsed.revalidated
TTL expired, 200 OKFull sanitize → parse → convert; the cached window is replaced.fresh

Cache entries are per feed, not per scan, so a partial hit refetches only the expired feeds. Only a complete, successfully parsed feed window is ever cached — a half-parsed feed is never served. Conditional requests still minimize transfer under ttl_seconds: 0.

Every served row carries its window's freshness in-band, so a consumer that never touches feeds still sees when it is reading a stale window. The stamp is window-level: it is identical on every row of one feed within one scan.

Negative caching and the failure fuse​

The TTL re-arms on every attempt, not only on success. A failed fetch records its error state and re-arms the timer, with a shorter fuse:

failure fuse = clamp(ttl_seconds / 4, 30s, 300s)

So a dead feed is re-attempted at most once per failure window rather than on every scan, and Retry-After politeness extends across scans instead of resetting with each one. The floor is 30s even under ttl_seconds: 0 — the always-live setting does not turn a dead feed into a per-scan hammer.

This is also why reading feeds right after a failure is instant: the failure was recorded, so there is nothing to re-attempt, and feeds cannot reach the fetcher in the first place.

The cache is bounded, and windows can be evicted​

The window cache is bounded by a 64 MiB budget on cached window bytes, shared across every feed of one source, plus an entry-count bound of one per subscription with a little headroom. When a new window pushes either bound over, the least-recently-used window is dropped. It is not configurable.

Eviction drops the window and its validators, and deliberately keeps the feed's health observation — feeds is specified to be a pure state read, so a feed must not lose its health just because its window was reclaimed. Two consequences follow from that split:

  • feeds.etag and feeds.last_modified read NULL for an evicted feed while last_status and item_count still describe the window that is gone. The next fetch for that feed is therefore an unconditional 200, not a 304.
  • A feed whose window was evicted refetches on its next items scan, even inside its TTL. "Within TTL" and "has rows to serve" are independent, and serving zero rows while feeds reported the feed healthy would be a silent capture gap, so the provider treats a lost window as a cache miss instead. The cost is one request; the alternative was an empty feed until the TTL expired.

A single window larger than the whole 64 MiB budget can never fit, so it is not stored at all — not even transiently — while the observation still records the successful parse. That feed then refetches on every scan.

The cache is process-lifetime state​

The window cache — validators included — lives in memory for the life of the process. A restart empties it, so the first post-restart scan presents no validators and re-fetches every feed as a full 200, never a 304. The conditional-GET savings vanish exactly when a fleet redeploys. They will extend across restarts only once a persistent cache lands behind the existing cache trait.

What caching does not promise​

  • No cross-feed consistency. A multi-feed scan can observe different feeds at different freshness. That is visible per row via window_status and per feed via feeds.last_fetch; it is not smoothed over.
  • No in-flight coalescing. Two concurrent scans that both find the same feed expired can both fetch it.
  • No background refresh. Nothing runs unbidden. Fetches ride on items scans, so continuity is the caller's cadence — a cron job or scheduled agent session running a scan more often than the fastest feed rolls its window. An entry that scrolls out between scans is never observed and leaves no in-band trace; the symptom is an aging feeds.last_fetch.

Politeness and bounds​

Every scan is bounded by the per-request timeout, the whole-scan deadline, the decoded-response cap, and max_concurrent.

max_concurrent bounds one data source's in-flight fetches — nothing wider. The semaphore lives on the engine, and one engine is built per registered rss source, so the bound is per source, and the three things it is not all matter for politeness:

  • It is not per host. There is no per-host accounting anywhere in the provider. With the default of 6, six subscriptions that happen to live on the same host are six concurrent requests to that host. If a host's feeds need gentler treatment, the lever is a lower max_concurrent on the source holding them — which is also the reason to split a hostile host's feeds into their own rss source.
  • It is not per process. Two rss sources in one context are two engines with two semaphores, so a process running both permits up to the sum. Size against the number of rss sources you register, not just the number you configured on one of them.
  • It is not global across replicas. Each process counts its own permits, so N replicas can present one host with up to N× the per-source bound, and a crash/restart loop the same.

Multiply all three together for the worst case a single feed host can see: max_concurrent × sources-sharing-that-host × replicas.

A self-identifying User-Agent is sent on every request, by default naming the Skardi version and the project URL. Feed servers routinely ban anonymous clients, so overriding it with something blank or generic is a way to get blocked.

Retries. 429, 500, 502, 503 and 504 — plus request timeouts and transport errors — are retried up to 3 attempts per redirect hop, with exponential backoff plus jitter. A response's Retry-After is honored and takes precedence when longer than the backoff, but it is capped at 10 seconds: an uncapped Retry-After is a one-header denial of service. Waits happen inside the scan, under its deadline. Redirects are followed up to 5 hops, each hop revalidated against the egress policy.

Any other 4xx — a 404, say — is terminal after a single request.

Terminal, not retried: a body that exceeds max_response_bytes (measured on the decompressed stream) aborts that feed after a single request, and a refused egress target never reaches a socket at all.


Field mapping​

The normative dialect → unified-schema mapping.

items columnRSS 2.0RSS 1.0 (RDF)Atom 1.0JSON Feed 1.x
guid<guid> → fallback <link><link> — see note<id>id
title<title><title><title> (text/html/xhtml normalized)title
link<link><link><link rel="alternate"> (first, else first link)url
author<author> / dc:creatordc:creator<author><name>authors[0].name
published<pubDate> (RFC 822)dc:date (ISO 8601)<published> (RFC 3339)date_published
updated= published — see note— (NULL)<updated> (RFC 3339)date_modified
contentcontent:encodedcontent:encoded<content>content_html / content_text
summary<description><description><summary>summary
categories<category>*dc:subject*<category term>*tags[]
enclosure_*<enclosure url/type/length>—<link rel="enclosure">attachments[0]

All date formats normalize to Timestamp(ms, UTC) at parse time. Fields a dialect lacks are usually null — nullability in the schema is the dialect-coverage annotation — but updated is the exception, and the two RSS dialects differ:

items.updated is not NULL on RSS 2.0. Neither RSS dialect has an update element, but feed-rs copies <pubDate> into updated for RSS 2.0 when the latter is absent (feed-rs-2.4.0/src/parser/rss2/mod.rs:279-281), so an RSS 2.0 item reads updated = published. Nothing does that on the RSS 1.0 path, so RSS 1.0 really is NULL. Both are pinned by corpus fixtures because they are dependency decisions, not ours.

User-visible consequence: WHERE updated IS NULL never matches an RSS 2.0 feed, and WHERE updated > published never matches one either. To find items a feed has actually revised, restrict to the dialects that carry a real update time — WHERE updated > published AND dialect IN ('atom', 'json-feed-1.x') — rather than relying on updated alone.

Also note categories is NULL, not [], when an entry carries no tags: an absent list reads the same as every other absent field on the row. IS NULL is the test; cardinality(categories) = 0 does not match.

feed, feed_url, position, and window_status are provider-synthesized, not wire fields, so they do not appear above. For content and summary the table names the wire field the value comes from; HTML-typed values are then converted to Markdown at extraction, and plain-text values (JSON Feed content_text, Atom type="text") pass through unchanged.

RSS 1.0 identity comes from <link>, not rdf:about. This is measured, not designed: feed-rs 2.4 never reads that attribute, so an RSS 1.0 item's guid is its <link>. The corpus fixture gives the two attributes different values, so a future feed-rs that starts mapping rdf:about fails a test rather than silently changing every archived item's identity.

An entry with neither an id nor a link has no usable identity, so it is dropped rather than given a synthetic one — a random id would give the same item a new (feed, guid) on every scan, breaking window identity and idempotent archiving. Dropped entries are counted in conformance_notes as entries-without-identity: <n>.


Conformance​

Two notions of dialect are kept apart on purpose, and disagreement between them is an observation about the feed, not an error. A feed that deviates still serves its rows.

dialect — what feed-rs actually parsed, a direct mapping of its FeedType:

rss-0.9x | rss-1.0 | rss-2.0 | atom | json-feed-1.x

dialect_declared — what the document claimed, sniffed lexically from the root element and version attribute (which still answers on bytes too broken to parse). It keeps version detail feed-rs collapses:

rss-0.9 | rss-0.91 | rss-0.92 | rss-1.0 | rss-2.0
atom-0.3 | atom-1.0
json-feed-1 | json-feed-1.1
unknown:<root element name>

Note atom-0.3 vs atom-1.0 here against a single atom in dialect — the distinction that matters is in dialect_declared.

unknown:<root element name> is the XML fallback only, not a universal one. The sniff reads a JSON document's jsonfeed.org/version/ marker and recognizes exactly 1 and 1.1; anything else — a JSON Feed declaring version 2, or a JSON document with no version marker at all — leaves dialect_declared NULL. So dialect_declared IS NULL on a document that parsed as json-feed-1.x means "a version this build does not know", which is a different thing from the XML side's unknown: prefix. The unknown: form is also capped at the same 512 characters last_error is, since the root element name is feed-supplied text of unbounded length.

conformance_notes​

A JSON array of strings, or [] when the document is clean — "parsed with nothing to note" and "never parsed" (NULL) are different states, and the column distinguishes them. The note shapes produced today:

NoteMeaning
sanitation: reencoded-to-utf8The document was re-encoded to UTF-8 (BOM / XML declaration / encoding sniff).
sanitation: stripped-control-charsControl characters illegal in XML were removed.
sanitation: escaped-naked-ampersandsBare &s that could not open a valid reference were escaped.
content-type-mismatch: served <type>, parsed <family>The HTTP Content-Type names a different feed family than the parse produced.
missing-required-field: <path>A dialect-required field the feed omitted.
entries-without-identity: <n>That many entries were dropped for having neither an id nor a link.

Only sanitation rungs that actually changed bytes are recorded, so a document rescued by ampersand escaping alone does not also claim to have been re-encoded. Each rung is a byte-level no-op on well-formed input, which is a contract test rather than an aspiration.

The content-type check only fires for media types that name a family: application/rss+xml / text/rss+xml → rss, application/atom+xml / text/atom+xml → atom, and application/feed+json → json. Note that application/json and text/json also count as naming the JSON family, so an XML feed served as application/json does produce a note. Types that name no family carry no opinion and produce none: text/xml, application/xml, application/octet-stream, anything unrecognized, and an absent header. The declared and parsed dialects cannot disagree in band: feed-rs dispatches its XML parsers on root element plus version attribute, so a document either parses as what it declares or fails outright; the mismatch check is therefore against the Content-Type, not against dialect_declared.

Required-field checks currently cover:

Parsed dialectChecked
rss-2.0channel/title, channel/link, channel/description
atomfeed/title, feed/updated
rss-0.9x, rss-1.0, json-feed-1.xnone yet — these extend from corpus evidence rather than being guessed

Tolerance floor​

Feeds misdescribe themselves constantly, and a strict parser refusing them is not useful. Skardi attempts a parse with a bounded, deterministic sanitation ladder applied first, and records what it took. This section is about what is not rescued — stated plainly, because the failure modes below are mostly quiet ones.

Documents with an internal DTD subset are refused outright​

A <!DOCTYPE … [ … ]> internal subset means the parse is refused before it starts: last_status = 'error', and last_error reads parse failed at refused-internal-dtd: internal DTD subset refused (entity-expansion guard) — the parenthetical included, which is worth knowing if you are matching on the string. The guard runs twice — once on the raw bytes and once again on the sanitized bytes, since a rung can reveal a doctype the first pass could not see (a lowercase <!doctype, a control character splitting the keyword, a UTF-16 document).

This is defence in depth, not a live rescue. feed-rs's quick-xml backend does not expand custom entities at the pinned versions, so a billion-laughs document would not actually inflate today. The guard exists so that a future dependency change cannot quietly make it inflate.

A document the ladder cannot rescue​

When the ladder is exhausted and the parse still fails, last_status = 'error' and last_error reads parse failed at strict-parse: <reason>. The reason is the parser's own structural message (e.g. unable to parse XML), never text lifted out of the document — see Reading last_error.

An Atom 0.3 document parses as an empty feed​

This one deserves its own paragraph, because a user will otherwise read it as a bug in Skardi.

An Atom 0.3 document reaches feed-rs's Atom parser by root-element name, but its namespace (http://purl.org/atom/ns#) maps to NS::Unknown, so every child element falls through the parser's Atom match arms and nothing is extracted. The result is:

  • zero items,
  • no error — last_status = 'fresh', not 'error',
  • dialect = 'atom', dialect_declared = 'atom-0.3',
  • and two notes: missing-required-field: feed/title and missing-required-field: feed/updated.

So a user subscribing to an Atom 0.3 feed sees an empty table, not a failure. The diagnosis is in dialect_declared and conformance_notes. Both facts are pinned by the fixture corpus; neither is a choice this design made.

What extensions_json does and does not carry​

extensions_json is bounded by what the feed-rs model exposes. It is not a catch-all for arbitrary unknown namespaces: a namespace feed-rs does not model is dropped at parse time and never reaches this provider, so it cannot be recovered here.

What it does carry, as a compact JSON object with deterministic key order:

  • media — MediaRSS objects beyond the single content already surfaced as the enclosure (each with url / content_type / size, plus the object's title, description, thumbnails, duration_secs when present),
  • source — the entry's source feed reference,
  • rights,
  • language — including an Atom entry's xml:lang.

The column is NULL when none of those are present.

Entries without identity​

Covered above under Field mapping: dropped, counted, and reported as a note.


Egress policy (SSRF)​

Feed URLs are agent-authored configuration, which makes them attacker-influenceable input: a prompt-injected agent can add http://169.254.169.254/latest/meta-data/ as a "feed" and have the server fetch cloud instance metadata on its behalf.

Skardi OSS does not sandbox fetch destinations. A subscribed feed URL — or a redirect it issues — reaches any address the host can route to, including link-local 169.254.169.254 and RFC 1918 private ranges. There is no reserved- range refusal shipped in OSS: the fetcher's egress check exists as a seam (EgressPolicy), but the constructor every production code path uses is AllowAll, which permits everything. This is an SSRF exposure the operator owns, not something Skardi filters on your behalf.

Given that, an operator should:

  • impose egress control at the infrastructure layer — an egress firewall or NetworkPolicy denying RFC 1918 / link-local / cloud metadata destinations from the process that runs Skardi, plus IMDSv2 with a hop limit on any cloud instance it runs on — and/or
  • inject a destination-filtering EgressPolicy at the call site that constructs the RSS engine, if embedding Skardi as a library.

Managed, default-on egress governance (the reserved-range refusal this section used to describe) is a Skardi Cloud feature, not an OSS one — see the Cloud egress design for the policy Cloud applies.

The seam still shapes two properties worth knowing, whichever policy ends up installed:

  • Redirects are re-checked. Every hop's target is validated against the active policy before the next request is built, so a policy that refuses a target catches it on a redirect hop too — the recorded error names the target, not the URL that was subscribed.
  • A mixed answer fails the whole lookup. If one hostname resolves to both a refused and an allowed address under a denying policy, the resolution is refused entirely rather than narrowed to the address that passed.

A subscription refused by an installed policy degrades exactly like an unreachable one: last_status = 'error', http_status NULL (no status was ever observed), zero rows in items, other feeds unaffected. last_error reads:

egress blocked: host '192.168.7.7' resolves to private address 192.168.7.7

Under the OSS default this never fires — AllowAll refuses nothing, so no subscription in OSS degrades this way unless you have injected your own denying EgressPolicy.


Content handling​

The Markdown storage contract​

items.content and items.summary are Markdown, converted once at extraction:

  • The conversion is deterministic — identical fragment in, byte-identical Markdown out. The fixture corpus pins converted output byte-for-byte, so a converter upgrade that changes output is a reviewed, visible change.
  • No HTML tag survives as markup. Elements with a Markdown equivalent convert (headings, lists, links, emphasis, code, tables, images); <script>/<style> and comments are dropped wholesale; remaining markup is reduced to its text content. <template> content is dropped rather than reduced to text — the converter walks a parsed tree, and its contents are not in it.
  • It never fails a feed: pathological HTML degrades to text content, not to an error.
  • Plain-text values pass through untouched — byte-exact, including anything tag-shaped in them. See the caveat below.
  • The source HTML is not retained.

The claim is "no HTML tag survives as markup", not "no raw HTML is stored". The stronger claim would be false in two shapes, and both are pinned by tests rather than being surprises:

  1. An attribute value's < survives unescaped, because there is no Markdown place to escape it into. <a href="#" title="\<script\>">t</a> converts to [t](# "\<script\>").
  2. A plain-text-typed value is not converted at all, so <content type="text">&lt;script&gt;alert(1)&lt;/script&gt;</content> stores the literal <script>alert(1)</script>. The XML entity references are transport encoding and are removed at extraction; what the feed meant was that text, and declaring type="text" is the feed asserting it is not markup. Skardi stores what it was told, which is the same passthrough described under Field mapping — it is not a conversion bug, and the corpus carries a fixture for exactly this hostile shape.

Neither changes what a consumer must do, because that was never "trust the stored value": the two rendering rules below are what make the value safe to display, and they cover both shapes.

The last point is a deliberate trade with a real cost: because the wire HTML is gone, a future better converter cannot re-render history. What remains possible is re-chunking and re-embedding from the stored Markdown — which is why an archive should keep content verbatim (see Pipeline examples).

The chain from wire to storage is: XML transport encoding (entity references, CDATA) removed at extraction → sanitation repairs, when any ran, already applied to the XML document and queryable in conformance_notes → the extracted HTML fragment converted to Markdown → stored unaltered. The fidelity claim is therefore extraction-plus-conversion faithful, not wire-faithful: document structure and text survive, the original markup does not.

Rendering it​

Stored Markdown is inert text, not an executable document — but rendering it is still a decision about untrusted input, because a feed's body is authored by whoever runs the feed. A consumer that renders the Markdown to HTML should:

  1. keep raw/inline HTML disabled in the renderer, and
  2. filter link destinations to safe schemes.

The second is not hypothetical: the conversion preserves a link's destination, so a feed can write click me) and the stored Markdown will carry that destination. The corpus pins exactly this case. A consumer that displays the value as plain text has nothing to do.

The XSS surface narrows from "escape arbitrary attacker HTML at every sink" to those two renderer settings. It does not disappear.

Feeding it to an LLM​

Prompt injection via feed content has no complete fix at any layer today, so the defense is containment, not prevention: keep the reading agent least-privileged and gate side effects deterministically. Skardi supplies one of those rails outright — subscription edits are configuration-only and human-visible — but not the other: OSS does not cap a hijacked agent's network reach by default (see Egress policy), so that containment is the operator's to add, at the infrastructure layer or via an injected EgressPolicy.

guid, link, author, and published are feed-asserted and unverifiable. A link pointing at an attacker's page is still a well-formed feed, so downstream citations inherit the feed's own trust level. What identity does guarantee: it is scoped by the feed discriminator, so one feed can neither collide with another feed's items nor rewrite an already-archived entry by reusing a guid.


Pipeline examples​

The live window is the provider's contract. History, chunking and citability are user-space SQL over it — an ordinary writable sqlite source, ordinary INSERT … SELECTs. Everything below runs in the provider's composition test suite.

The archive schema​

Two tables. news_items keeps one row per entry with content exactly as items served it, and is the anti-join target. news_chunks is derived and disposable — it can be dropped and rebuilt from news_items alone.

CREATE TABLE IF NOT EXISTS news_items (
feed TEXT NOT NULL, guid TEXT NOT NULL, title TEXT, link TEXT, author TEXT,
published TIMESTAMP, content TEXT, PRIMARY KEY (feed, guid));

CREATE TABLE IF NOT EXISTS news_chunks (
feed TEXT NOT NULL, guid TEXT NOT NULL, chunk_idx INTEGER NOT NULL,
chunk_text TEXT NOT NULL, embedding BLOB, ingested_at TIMESTAMP,
PRIMARY KEY (feed, guid, chunk_idx));

Statement A — append what the archive has not seen​

INSERT INTO archive.main.news_items (feed, guid, title, link, author, published, content)
SELECT i.feed, i.guid, i.title, i.link, i.author, i.published, COALESCE(i.content, i.summary)
FROM news.main.items i
LEFT JOIN archive.main.news_items a ON a.feed = i.feed AND a.guid = i.guid
WHERE a.guid IS NULL;

It is the absence of a matching archive row — not a timestamp or a high-water mark — that decides what is new. So a window that rolled backwards, a feed that re-served an old entry, and a second run five seconds later all add nothing.

Statement B — chunk the archived text​

INSERT INTO archive.main.news_chunks (feed, guid, chunk_idx, chunk_text, embedding, ingested_at)
SELECT s.feed, s.guid,
ROW_NUMBER() OVER (PARTITION BY s.feed, s.guid) - 1 AS chunk_idx,
s.chunk_text, NULL AS embedding, now() AS ingested_at
FROM (
SELECT n.feed, n.guid, UNNEST(chunk('markdown', n.content, 1200, 120)) AS chunk_text
FROM archive.main.news_items n
LEFT JOIN archive.main.news_chunks e ON e.feed = n.feed AND e.guid = n.guid
WHERE e.guid IS NULL AND n.content IS NOT NULL
) s;

Two things to note:

  • It reads archive.main.news_items, never news.main.items. Chunking is a pure function of retained content, so it costs no network and survives the live window rolling out from under it. Changing the chunk size later is a DELETE FROM news_chunks plus a re-run with new parameters — no refetch, and it works for entries the feed has already dropped.
  • DataFusion has no WITH ORDINALITY, and the shipped idiom (docs/chunk.md, "Inline ingestion") is a plain UNNEST(chunk(...)) subquery — so the chunk index comes from a window function. ROW_NUMBER with no ORDER BY inside the window leaves which chunk gets which index unspecified; what it guarantees is that the indices are a dense 0..n-1 per (feed, guid), which is what makes (feed, guid, chunk_idx) a usable primary key.

To embed inline, replace NULL AS embedding with vec_to_binary(candle('models/bge-small-en-v1.5', s.chunk_text)) (requires the candle feature; vec_to_binary is what makes the List<Float32> storable in a sqlite BLOB).

The closing health report​

A sync run's last statement: the degraded subscriptions, with the reason and the as-of time.

SELECT name, last_status, last_error, last_fetch
FROM news.main.feeds
WHERE last_status IN ('error', 'never', 'stale-error')
ORDER BY name;

An empty report means every feed is healthy. The read is free — feeds is pure state, no fetches — and the report is an observation about the run, not a gate on it: a degraded feed changes the output, never the exit status, and it keeps serving its stale window in items meanwhile.

One archive gotcha worth knowing​

A sqlite TIMESTAMP column comes back through the sqlite provider as Arrow Utf8, so an archived published round-trips as a string:

SELECT guid, title, link, published FROM archive.main.news_items WHERE guid = 'news-1';
-- published: '2026-07-20T10:00:00Z' -- a string, not a Timestamp

ORDER BY published on the archive is therefore a string ordering. That happens to be correct for the ISO-8601 rendering it stores, but it is not a timestamp comparison — do not mix it with interval arithmetic or expect it to survive a different rendering. On news.main.items, published is a real Timestamp(ms, UTC).

Federated joins​

items joins anything else registered in the same context, on ordinary columns:

SELECT i.guid, m.tier
FROM news.main.items i
JOIN meta.main.feed_meta m ON m.feed = i.feed
ORDER BY i.guid;

Note that a join predicate is not an equality against a literal, so it prunes nothing — every subscription is fetched. See Pruning.


Pruning and what it costs​

feed and feed_url predicates prune partitions before any fetch, which makes pruning a cost control, not just a plan detail. RSS has no query parameters, so nothing else can reach the wire; every other predicate stays in DataFusion.

Query shapeFeeds fetched
WHERE feed = 'b'exactly one
WHERE feed_url = '\<url\>'every subscription using that URL
WHERE feed IN ('a','b','c','d') (any length)exactly those members
WHERE feed = 'a' OR feed = 'c'exactly those two
WHERE feed = 'a' OR feed_url = '\<url\>'all of them — see below
WHERE feed IN (…) AND feed = 'b'the intersection — one
JOIN … ON m.feed = i.feedall of them
LIMIT n with no ORDER BYas many as it takes to fill n — a nondeterministic subset
ORDER BY … LIMIT nall of them (Top-K consumes every partition)

What prunes​

Three shapes over feed or feed_url prune, and nothing else does:

  • equality against a literal — feed = 'b', either operand order;
  • a non-negated IN list of literals, at any length;
  • a disjunction of either of the above over one of the two columns — feed = 'a' OR feed = 'c' visits exactly a and c.

The disjunction case is what makes a short IN list prune. DataFusion rewrites feed IN ('a','c') into feed = 'a' OR feed = 'c' before the predicate reaches the provider, for lists of three or fewer values, so a short IN and the hand-written OR are the same predicate by the time it is classified — and both prune. EXPLAIN shows this: the pruned predicate appears as the table scan's full_filters with no FilterExec above it.

Duplicates and unknown names need no care. feed = 'a' OR feed = 'a' visits a once, and a name matching no subscription simply contributes nothing to the union — feed = 'a' OR feed = 'typo' visits only a, and a predicate naming no subscription at all fetches nothing.

Residual limitation: a disjunction may not mix the two feed columns​

A disjunction prunes only when every branch names the same column, so WHERE feed = 'a' OR feed_url = '\<url\>' fetches every subscription. So does any disjunction with a branch that is not itself prunable — feed = 'a' OR title = 'x', feed = 'a' OR feed > 'b', a negated branch, or an AND nested inside the OR.

The rows are still correct in every one of these cases — the predicate is applied above the scan by DataFusion. The cost is HTTP requests, not correctness.

When the fetch cost matters, express the feed set with one column: an IN list, or an OR chain over feed alone.


Troubleshooting​

Nothing came back​

A subscription that served no rows leaves no trace in items — absence has no in-band carrier in a relational result. feeds is the authoritative signal, and the prescribed check is an anti-join, run alongside any read where completeness matters:

SELECT f.name, f.last_status, f.last_error
FROM news.main.feeds f
LEFT JOIN news.main.items i ON i.feed = f.name
WHERE i.feed IS NULL
ORDER BY f.name;

This check fetches. feeds is a pure state read, but the items side of the anti-join carries no feed predicate, so it is a full scan: every subscription past its TTL is fetched. A cold absence check over 50 subscriptions issues 50 requests. Run it alongside a read whose scan has already warmed the cache and it costs nothing extra; run it on its own and it is the most expensive query in this document.

One consequence of the two tables being scanned in the same query: there is no ordering guarantee between them, so the feeds side can be read before the items side's fetch has recorded its result. A feed the same query just fetched can therefore report never with a NULL last_error — the fetch happened, the health write landed after the read. It self-corrects on the next query; if a never row surprises you, read feeds again on its own (that read really is free).

Absence alone is not a verdict — several different situations produce it, and last_status is what separates them:

last_statusitem_countDiagnosis
fresh / revalidated0Legitimately empty. The feed was fetched and parsed successfully and simply has no entries right now. Nothing is wrong. (If the feed does have entries in a browser, check dialect_declared — see Atom 0.3.)
errorNULLDead. The fetch or parse failed and no window was ever cached, so there is nothing to serve. last_error says why.
errornon-NULLDead, but it held a window once. The item_count describes a window that is gone. Reached one way today: a 304 came back for a window the cache had already evicted — last_error says so, and the next scan refetches unconditionally.
neverNULLNever attempted, or dropped at the deadline. Either no items scan has run since registration, every scan so far pruned this feed away, or — the one that is easy to miss — every scan so far ran out of scan_timeout_seconds before this feed's turn. A serve dropped at the scan deadline writes no health state at all, exactly like a partition the plan never launched, so the feed keeps reading never with a NULL last_error indefinitely. last_error being NULL is what distinguishes all three of these from a real failure; nothing distinguishes them from each other in this table. If a never row persists across scans you believe should have reached it, raise scan_timeout_seconds and look at the debug log, where each serve records one line.

stale-error does not normally appear in this check at all: a feed in that state has a cached window and is serving it, so the anti-join does not find it. It shows up in the health report instead, and on its rows' window_status. A failure never clears a cached window, and it leaves item_count describing that window rather than the failed attempt.

The never row has a subtlety​

Because fetch and health refresh both ride on the items scan, a query that prunes also bounds the side effects: an un-launched feed is neither fetched nor health-refreshed, and its feeds observation keeps aging. So never does not only mean "the source was just registered" — it can equally mean "every scan so far happened to skip this feed".

The shape that bites is a bare LIMIT:

SELECT title, link FROM news.main.items LIMIT 20;   -- nondeterministic feed sample

Items have no global order, so a LIMIT with no ORDER BY serves a nondeterministic subset of feeds — two identical queries can touch different feeds and do different amounts of work, and the feeds they did not touch stay never. This is requested truncation, not concealed failure: the result's feed column is the scan's coverage manifest. But completeness-sensitive reads should not use a bare LIMIT — use ORDER BY … LIMIT (a Top-K consumes every partition) or no LIMIT at all.

A feed's rows look old​

Check window_status on the rows themselves:

SELECT feed, window_status, count(*) FROM news.main.items GROUP BY feed, window_status;

stale-error means the TTL expired, the refetch failed, and the previous window is being served. The rows are real — they are the last complete window that parsed — but they are not current. feeds.last_error and feeds.last_fetch say why and as of when. Everything else in the scan is unaffected: one dead feed among fifty never fails the scan.

Reading last_error​

last_error is bounded at 512 characters (the provider's MAX_ERROR_CHARS; the numeral here and in the semantics overlay are that constant's value, which is defined in error.rs).

What it may contain. A last_error may quote a feed-supplied token that sat in a structural position — an element or attribute name, an attribute value the parser had to interpret (a type/MIME string), a declared version string, or the JSON member value that failed a type check. Those fragments are kept deliberately: they are what makes a malformed feed diagnosable, and an unsupported version or an unknown content type cannot be acted on without them.

It does not quote a value the provider reads as content — the character data of an element, a title, a description, an entry body. That holds even when the document fails to parse for an unrelated reason: the error names the mismatched tag, not the <summary> next to it.

The cap is the only bound on a quoted fragment's length, and that matters because a feed author who wants arbitrary text of their choosing in this column can get it, by putting that text in a structural position rather than a prose one. Measured: a ~1 KB string in a JSON Feed's tags, authors, attachments, or size_in_bytes is quoted verbatim up to the cap. Since the closing health report SELECTs this column straight into a reading agent's context, treat its contents as feed-authored text, on the same footing as content and summary — see Feeding it to an LLM.

It also never contains the request URL. A transport error folds in reqwest's underlying cause chain — "Connection refused", a DNS failure, at most a host or an ip:port — with the URL clause reqwest would normally append dropped: on a redirected fetch that URL is whatever the last Location pointed at, and either way it can carry a private query token.

The boundary above is measured at the pinned dependency versions and enforced by parse_failure_last_error_quotes_structure_not_prose, which runs a table of document shapes and asserts, shape by shape, which ones' text reaches the column and which do not. It is not a closed list of what a malformed document can produce; if a dependency upgrade moves the boundary, that test fails.

By the shape of the message:

last_error starts withStageWhat to do
egress blocked: …Egress policy, before connectAn installed EgressPolicy refused the resolved address, on the initial URL or a redirect hop. Never produced under the OSS default (AllowAll) — only reachable if an operator has injected a denying policy. The message names the address and the reason given. Not a network problem — see Egress policy.
parse failed at refused-internal-dtd: …Pre-parse guardThe document carries an internal DTD subset. Refused by policy; there is no repair path.
parse failed at strict-parse: …Parse, ladder exhaustedThe document is not recoverable as a feed at these dependency versions. The trailing reason is the parser's structural message.
http status <n>FetchAn HTTP error. 429/5xx were already retried; 4xx other than 429 is terminal after one request.
request timed out after <n>sFetchThe per-request timeout fired. Distinct from the scan deadline — if you see this, raising request_timeout_seconds is the lever, not scan_timeout_seconds.
response exceeded <n> bytesFetchThe decoded body passed max_response_bytes. Terminal, not retried.
too many redirects (limit 5)FetchThe redirect chain was longer than the hop budget.
invalid feed url: …FetchThe URL — or a redirect Location — is not a usable http(s) URL.
transport error: …FetchA connection or I/O failure, after retries were exhausted. Carries the underlying cause chain (at most a host or ip:port); never the request URL.
revalidated (304) but the cached window had already been evicted …CacheA 304 came back for a window the bounded cache had already evicted. Self-correcting on the next scan — the next attempt refetches unconditionally. This is the one shape that pairs last_status = 'error' with a non-NULL item_count.

A feed serves rows but they are missing fields​

Read conformance_notes before suspecting the provider:

SELECT name, dialect, dialect_declared, conformance_notes, item_count
FROM news.main.feeds
WHERE conformance_notes <> '[]'
ORDER BY name;

missing-required-field entries mean the feed omitted something its own dialect requires. entries-without-identity: <n> means that many entries were dropped for having neither an id nor a link. content-type-mismatch is usually harmless — a misconfigured feed host, not a broken feed.

Every feed refetched after a restart​

Expected. The cache is process-lifetime state, so the first scan after a restart is a full 200 for every feed it visits — see The cache is process-lifetime state.

A feed host is rate-limiting us​

max_concurrent bounds one source's in-flight fetches, not one host's. Six subscriptions on the same host are six concurrent requests to it by default, and that multiplies again by the number of rss sources sharing the host and by the number of replicas — see Politeness for the full worst case and the levers.


Next​