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 featureThe whole provider is gated behind the
rssCargo feature. Without it, a context declaring anrsssource 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_writeis rejected at registration. Agents manage subscriptions by editingctx.yaml(or the OPML file) and reloading.
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:
<source name>.main.feeds -- one row per subscription: fetch health
<source name>.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.
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.
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.
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
- 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 | Default | Meaning |
|---|---|---|
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_seconds |
900 |
Per-feed cache TTL. 0 means always-live: every items scan revalidates every feed it visits. The only bound where zero is legal. |
max_concurrent |
6 |
Feeds 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_seconds |
10 |
Timeout for one feed HTTP request. |
scan_timeout_seconds |
60 |
Deadline for one whole scan across every subscribed feed. |
max_response_bytes |
5242880 (5 MiB) |
Cap on one decoded response body, so a compressed payload cannot inflate past it. |
user_agent |
skardi-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.
| Field | Ceiling | Why |
|---|---|---|
ttl_seconds |
one year | The 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_seconds |
one hour | The 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: 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.
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.
| Column | Arrow type | Null | Notes |
|---|---|---|---|
name |
Utf8 |
no | Configured name; defaults to the URL; unique. |
url |
Utf8 |
no | Subscription URL. |
title |
Utf8 |
yes | Wire title, once fetched. |
site_url |
Utf8 |
yes | The feed's HTML alternate. |
description |
Utf8 |
yes | |
last_fetch |
Timestamp(ms, UTC) |
yes | Time of the last attempt, success or not. |
last_status |
Utf8 |
no | never | fresh | revalidated | stale-error | error |
http_status |
UInt16 |
yes | Last HTTP response code; NULL when no status was ever observed (e.g. a refused egress target). |
last_error |
Utf8 |
yes | Failure reason, bounded and body-free (see Reading last_error). |
etag |
Utf8 |
yes | Conditional-request validator. |
last_modified |
Utf8 |
yes | Conditional-request validator. |
dialect |
Utf8 |
yes | Parsed dialect (see Conformance). |
dialect_declared |
Utf8 |
yes | What the document claimed. |
conformance_notes |
Utf8 |
yes | JSON array of note strings; [] means clean. |
item_count |
UInt64 |
yes | Entries in the current window. |
The live union across subscriptions; identity is (feed, guid).
| Column | Arrow type | Null | Notes |
|---|---|---|---|
feed |
Utf8 |
no | Subscription name; pushdown-prunable. |
feed_url |
Utf8 |
no | Pushdown-prunable. |
guid |
Utf8 |
no | Stable item identity (see Field mapping). |
title |
Utf8 |
yes | |
link |
Utf8 |
yes | |
author |
Utf8 |
yes | |
published |
Timestamp(ms, UTC) |
yes | |
updated |
Timestamp(ms, UTC) |
yes | |
content |
Utf8 |
yes | Markdown, converted at extraction. |
summary |
Utf8 |
yes | Markdown, same conversion. |
categories |
List<Utf8> |
yes | |
enclosure_url |
Utf8 |
yes | Podcast/media attachment. |
enclosure_type |
Utf8 |
yes | MIME type. |
enclosure_length |
UInt64 |
yes | Bytes. |
position |
UInt32 |
no | Document order within the feed's window. |
window_status |
Utf8 |
no | fresh | revalidated | stale-error — the serving window's freshness. |
extensions_json |
Utf8 |
yes | Non-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.
Live reads are the default. Each feed independently falls into one of three
tiers on an items scan:
| Tier | What happens | window_status / last_status |
|---|---|---|
| Within TTL | Serve 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 Modified |
Header-only revalidation; the cache is re-armed and not reparsed. | revalidated |
TTL expired, 200 OK |
Full 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.
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 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.etagandfeeds.last_modifiedread NULL for an evicted feed whilelast_statusanditem_countstill describe the window that is gone. The next fetch for that feed is therefore an unconditional200, not a304.- A feed whose window was evicted refetches on its next
itemsscan, even inside its TTL. "Within TTL" and "has rows to serve" are independent, and serving zero rows whilefeedsreported 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 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.
- No cross-feed consistency. A multi-feed scan can observe different feeds at
different freshness. That is visible per row via
window_statusand per feed viafeeds.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
itemsscans, 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 agingfeeds.last_fetch.
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 lowermax_concurrenton the source holding them — which is also the reason to split a hostile host's feeds into their ownrsssource. - It is not per process. Two
rsssources in one context are two engines with two semaphores, so a process running both permits up to the sum. Size against the number ofrsssources 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.
The normative dialect → unified-schema mapping.
items column |
RSS 2.0 | RSS 1.0 (RDF) | Atom 1.0 | JSON 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:creator |
dc: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 |
content |
content:encoded |
content: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.updatedis not NULL on RSS 2.0. Neither RSS dialect has an update element, butfeed-rscopies<pubDate>intoupdatedfor 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 readsupdated = 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 NULLnever matches an RSS 2.0 feed, andWHERE updated > publishednever 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 onupdatedalone.
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>, notrdf:about. This is measured, not designed:feed-rs2.4 never reads that attribute, so an RSS 1.0 item'sguidis its<link>. The corpus fixture gives the two attributes different values, so a futurefeed-rsthat starts mappingrdf:aboutfails 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>.
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.
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:
| Note | Meaning |
|---|---|
sanitation: reencoded-to-utf8 |
The document was re-encoded to UTF-8 (BOM / XML declaration / encoding sniff). |
sanitation: stripped-control-chars |
Control characters illegal in XML were removed. |
sanitation: escaped-naked-ampersands |
Bare &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 dialect | Checked |
|---|---|
rss-2.0 |
channel/title, channel/link, channel/description |
atom |
feed/title, feed/updated |
rss-0.9x, rss-1.0, json-feed-1.x |
none yet — these extend from corpus evidence rather than being guessed |
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.
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.
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.
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/titleandmissing-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.
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 withurl/content_type/size, plus the object'stitle,description,thumbnails,duration_secswhen present),source— the entry's source feed reference,rights,language— including an Atom entry'sxml:lang.
The column is NULL when none of those are present.
Covered above under Field mapping: dropped, counted, and reported as a note.
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
NetworkPolicydenying 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
EgressPolicyat 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.
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:
- 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>").- A plain-text-typed value is not converted at all, so
<content type="text"><script>alert(1)</script></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 declaringtype="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.
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:
- keep raw/inline HTML disabled in the renderer, and
- 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](javascript:void(0)) 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.
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.
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.
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));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.
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, nevernews.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 aDELETE FROM news_chunksplus 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 plainUNNEST(chunk(...))subquery — so the chunk index comes from a window function.ROW_NUMBERwith noORDER BYinside the window leaves which chunk gets which index unspecified; what it guarantees is that the indices are a dense0..n-1per(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).
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.
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 TimestampORDER 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).
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.
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 shape | Feeds 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.feed |
all of them |
LIMIT n with no ORDER BY |
as many as it takes to fill n — a nondeterministic subset |
ORDER BY … LIMIT n |
all of them (Top-K consumes every partition) |
Three shapes over feed or feed_url prune, and nothing else does:
- equality against a literal —
feed = 'b', either operand order; - a non-negated
INlist of literals, at any length; - a disjunction of either of the above over one of the two columns —
feed = 'a' OR feed = 'c'visits exactlyaandc.
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.
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.
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.
feedsis a pure state read, but theitemsside of the anti-join carries nofeedpredicate, 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
feedsside can be read before theitemsside's fetch has recorded its result. A feed the same query just fetched can therefore reportneverwith a NULLlast_error— the fetch happened, the health write landed after the read. It self-corrects on the next query; if aneverrow surprises you, readfeedsagain 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_status |
item_count |
Diagnosis |
|---|---|---|
fresh / revalidated |
0 |
Legitimately 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.) |
error |
NULL |
Dead. The fetch or parse failed and no window was ever cached, so there is nothing to serve. last_error says why. |
error |
non-NULL |
Dead, 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. |
never |
NULL |
Never 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.
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 sampleItems 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.
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.
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.
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 with |
Stage | What to do |
|---|---|---|
egress blocked: … |
Egress policy, before connect | An 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 guard | The document carries an internal DTD subset. Refused by policy; there is no repair path. |
parse failed at strict-parse: … |
Parse, ladder exhausted | The document is not recoverable as a feed at these dependency versions. The trailing reason is the parser's structural message. |
http status <n> |
Fetch | An HTTP error. 429/5xx were already retried; 4xx other than 429 is terminal after one request. |
request timed out after <n>s |
Fetch | The 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> bytes |
Fetch | The decoded body passed max_response_bytes. Terminal, not retried. |
too many redirects (limit 5) |
Fetch | The redirect chain was longer than the hop budget. |
invalid feed url: … |
Fetch | The URL — or a redirect Location — is not a usable http(s) URL. |
transport error: … |
Fetch | A connection or I/O failure, after retries were exhausted. |
revalidated (304) but the cached window had already been evicted … |
Cache | A 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. |
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.
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.
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.
- Text chunking — the
chunk()UDF used in the ingest pipeline. - Catalog semantics — how the bundled overlay at
docs/rss/semantics.yamlis discovered and merged. - Catalog mode — the
hierarchy_level: catalogregistration shape. - Federated queries — joining
itemsagainst other sources.