28. Vulnerability intelligence is a record kind of its own, read from its source and never inferred from what a feed left out
This decision is reproduced from the pinned backend revision. Read amendment notices and the current architecture before treating historical statements as current behavior.
Context
ADR 27 gave the platform one half of vulnerability management: what an asset currently has. BE-035 is the other half — what is known to be wrong with software — and BE-036 will be the match between them. The card asks for a pipeline of feeds that keeps where every piece of intelligence came from and how fresh it is, that normalises ecosystems and package names, and whose failure cannot corrupt the inventory.
v1 did this from the agent. Its vulnerability scanner inferred an OSV ecosystem
from the package manager, queried api.osv.dev from every endpoint, and sent
findings to the server. Almost everything that went wrong there is a lesson this
record answers:
- the ecosystem had no release.
dpkgbecameDebianorUbuntuwith no version, so a Debian 12 host was compared against whatever release OSV chose;dpkgon any distribution it did not recognise becameDebian;pacmanbecameArch Linux, which is not an OSV ecosystem at all, and every Arch host was reported clean; - binary packages were matched against advisories that name source packages.
Debian's advisories name
openssl; a host haslibssl3installed; - an unchanged package list skipped the query, so an advisory published after the last upgrade was never matched until something else changed;
- a scan reported complete with nothing observed resolved every open finding. The server reconciled findings against the list a scan observed, and a skipped query observes nothing, so every other scan closed everything and the next one opened it again;
- a finding was keyed by the OSV id, so the PyPI advisory and the GitHub advisory for one CVE were two findings.
What the feeds actually are was measured on 2026-09-13. OSV's public export has,
per ecosystem, an index of every current record and when it last changed
(modified_id.csv) and each record on its own. Debian's index lists 66,660
records and modified 54,643 of them in thirty days, mostly by re-exporting them
unchanged; Ubuntu's archive of the same records is 682 MB and one of its kernel
advisories is 1.5 MB. Debian, Ubuntu, Alpine and Rocky Linux name source
packages; AlmaLinux names binaries; Red Hat scopes an advisory to a product
stream such as rhel_e4s:8.8::baseos and SUSE to a product's marketing name.
Decision
Vulnerability intelligence is a record kind of its own — its own contract, topic, store and processes — read from its source by the one process that reaches the internet, kept with where and when it was read, and never concluded from a feed that failed or from a record a feed stopped listing.
The one process that reaches the internet holds no store
seagull.vulnerability.v1 carries an Advisory — what a source says about one
vulnerability, translated — and a FeedSync — what one attempt to follow a feed
found. cmd/advisory-importer reads the feeds, translates what they say and
publishes both to security.advisories, a compacted topic of one partition
keyed by what each record is about: an advisory by its source and id, so
compaction keeps the newest version of each, and a sync by its feed.
cmd/advisory-writer consumes that topic into ClickHouse.
The importer writing the store directly was the smaller design and was weighed properly. It loses on the reasons that decide this platform:
- The importer is the one process exposed to content from the internet. It
parses JSON anybody on the path to a feed could have shaped, and reaching out
of the stack is its whole job. On the backbone its whole reach is
producing to one topic, which BE-004's ACLs can restrict to exactly that; with
the store in front of it, it would hold a credential to the database every
tenant's telemetry lives in.
tests/architecturerefusescmd/advisory-importerany import of a store or of the inventory, andinternal/advisoryfeedany import of the inventory domain. - A feed outage and a store outage fail apart. A feed that cannot be read stops nothing but the importer; a store that cannot be reached holds up neither the feeds nor anything else, and becomes lag on one consumer group.
- Intelligence changes are events. BE-036's matcher can consume the topic and re-match what an advisory names when the advisory changes, which is exactly what v1's skipped query never did.
- The store is rebuilt from the backbone without reading the internet again, and it is the grain every other record kind already uses.
The cost is a contracts release, two processes where one could have done, and a replay of the topic when the importer starts.
What the platform reads
OSV and nothing else, because it is what the need requires: it carries the distributions' own advisories — Debian's security tracker, Ubuntu's CVE tracker, Alpine's secdb, Rocky's and AlmaLinux's errata — with the CVE each derives from and the CVSS vectors their authors assigned. No NVD feed, no vendor feed read directly and no score computed; each is a source to add when a need names it.
The importer follows a feed's index and fetches each record whose listed change is newer than what the platform holds. That is one path for the first read and every later one, needs no disk and holds one record at a time; the per-ecosystem archive would be 13 times less transfer the first time and is not worth a second path. The export is read over https only, redirects included, from OSV's bucket or from a mirror laid out the same way and signed by a bundle a deployment names: what a feed says decides what the platform calls vulnerable, and a feed read in the clear is one anybody on the path can edit into saying a package is fixed.
Five distributions are supported: Debian, Ubuntu, Alpine, Rocky Linux and AlmaLinux. The others are left out on purpose and the platform says so:
- Red Hat and SUSE scope an advisory to a product stream or a product module no inventory field names, and a guessed stream is a wrong answer;
- Ubuntu Pro fixes land in pockets of their own,
Ubuntu:Pro:22.04:LTS, which apply only to an asset attached to Pro, and nothing says whether one is; - language ecosystems — PyPI, npm, Go — have no collector;
- Arch Linux has no OSV ecosystem.
An ecosystem is named with its release, and both sides meet on one key
The platform's name for an ecosystem is OSV's spelling with the release in it —
Debian:12, Ubuntu:22.04:LTS, Alpine:v3.19, Rocky Linux:9,
AlmaLinux:9 — because a package is compared against one release of its
distribution and the release is part of what is being named. internal/vulnerability
builds that name from a feed's spelling and from what an asset reports about
itself with the same function, so an advisory entry and an installed package are
a lookup rather than a guess. A package is named the way that distribution's
advisories name it: the source package for Debian, Ubuntu, Alpine and Rocky —
read out of dpkg's openssl (3.0.11-1) or rpm's
openssl-3.0.7-27.el9.src.rpm — and the binary for AlmaLinux.
An asset the platform cannot assess says why. A derivative that names no distribution the platform reads, a package another manager installed, a host that names no release: each is an error of its own. Cannot assess is not the same answer as not affected, and v1's Arch hosts are what conflating them looks like.
An advisory is what its source said, translated
OSV is translated and never adopted, as Sigma is under
ADR 22. What the platform's
advisory can say is kept faithfully: identity, when it changed, when it was
withdrawn, the three kinds of name a record has for other records — aliases
for the same vulnerability, upstream for what a distribution's advisory derives
from, related for the rest — its severities as the vectors or ratings their
authors wrote, and per package the ranges of versions affected and the versions
listed. A version list is kept whole even where it only expands a range:
telling the two apart needs the ecosystem's ordering of versions, which belongs
to the matcher.
What it cannot say is left out, and nothing is guessed in its place: an entry in an ecosystem no asset can be placed in, a range stated in commits, a severity on a scale no build reads, references and the database's private fields. Prose is cut to fit because it names nothing; a name, an id, a version or a boundary that cannot be read refuses the record. A record in a later major version of the format is refused rather than half read. A time left at the zero instant is read as no time at all — Debian's converter writes one where it knows no publication date, and the first real import found 37 — and a time the platform cannot hold is refused where the advisory is read, rather than published and then refused by the store while the importer believed it held it.
Provenance.normalization is the version of these rules. A build that changes
what a translation says raises it, and the importer reads every record it holds
from an older reading again.
Provenance is the platform's to write
Every advisory carries the feed it was read from, that feed's index version, the
URL the bytes came from, when they were fetched, their SHA-256, the format and
its version, and the rules it was translated with. A translation says what the
feed said; only the importer knows where and when it asked, so it writes the rest
itself. vulnerability.Validate refuses an advisory missing any of it, at the
importer and again at the writer, so no piece of intelligence reaches the store
without saying where it came from. The digest was checked against the source's
live bytes after the first real import and matched.
Freshness is two instants, and an attempt that failed moves neither
A FeedSync says when the importer asked (checked_at), when the platform last
held everything the feed listed (synced_at), and the newest change the feed
itself lists (newest_listed). The first two separate a platform that is behind
from one that is asking; the last separates a stale feed from a stale copy of a
fresh one — OSV's per-release folders stopped updating in October 2024, and read
through this they would say so. An attempt that failed or stopped part way carries
synced_at forward from the last complete one, so freshness never looks better
than it is. The same instants are gauges per feed, which a deployment alerts on.
A gap in intelligence is never an answer
Nothing is concluded from what a feed did not say. An advisory a feed's index stops listing is kept exactly as it was, because an index is not a tombstone — OSV leaves records behind in its bucket without withdrawing them and renames records between exporters — and an advisory is withdrawn only when its source says so, as a newer version. A failed attempt publishes nothing but its own failure; one that stops part way publishes what it read and owes the rest; a record that is not an advisory is refused alone, counted, and asked for again only once the feed changes it. The platform's copy of a feed can be old; it is never wrong in a way it does not report. BE-036 inherits the rule: a finding is not resolved by intelligence that failed to arrive.
Every version is kept, and a moved clock is not a version
vulnerability_advisories holds a row per version of an advisory — its source,
its id, the modification time the source gave it and the rules it was read with —
and vulnerability_affected a row per package a version affects, ordered by
ecosystem and package, which is how a matcher asks. Nothing replaces another
version and nothing is deleted, so a finding made from a version stays
explicable after the source moves on. The newest version of an advisory is the
one with the greatest modification time, read with the greatest normalisation that
version was stored under; the definition is stated once, as a query, in the
integration suite, and BE-036 starts from it.
Most of a feed's churn is re-exports that change nothing but the modification time. The importer compares what a record says, apart from when and where it was read, with what it holds, and publishes it only if it differs; a new reading with newer rules is always published. A version older than the one held is never published, so a record that took the long way through a cache cannot put an advisory back; and an advisory that says it changed more than an hour after it was read is refused, as is anything on the log the importer could not have read yet, because holding it would keep every real change older than it out.
Caching, retry and failure
The importer reads the whole topic back before it asks a feed anything, so it
knows what it holds, and its cache is the backbone rather than a disk. The index
is asked for with If-None-Match; only records listed as newer than what is held
are fetched, eight at a time by default across every feed. An origin that is
unreachable or busy is asked again with a widening delay a bounded number of
times, and then the attempt stops rather than hammering it; an origin that
answered anything else was answered. After an attempt that did not complete, the
feed is tried again sooner, backing off to the interval. Each feed is followed on
its own, so one that fails holds up no other.
What this deliberately does not do
No matching and no findings. That is BE-036, and so is the policy that picks a severity out of the vectors an advisory carries.
No retraction by absence. Reading a complete index as a snapshot, the way
ADR 27 reads a scan, would retire advisories a feed stopped listing. It is left
out because the only case seen is an exporter renaming its records, and the cost
of being wrong is v1's: intelligence that quietly stopped saying a package is
vulnerable. listed beside held on every sync makes the drift visible.
No read API. Nothing outside the platform reads advisories yet. The contract is the message a read will return, as ADR 13 requires.
No archive of the raw records. The digest and the location say what was read; keeping the bytes would make the platform a mirror of OSV.
Consequences
- Contracts
v0.14.0carriesseagull.vulnerability.v1. - Two processes more, the ninth and tenth that run for good, and two topics:
security.advisories, compacted and kept, andsecurity.advisories.quarantinefor what the writer refuses. - Three tables more, none partitioned and none expiring but the one holding sync attempts. ADR 12 has one more class decided.
- The stack gains a network with a way out,
feeds, and only the importer is on it. - The first read of a feed is its whole index. Alpine's 4,657 records took three minutes at eight fetches at a time and a restart afterwards re-read none of them; Debian's, fourteen times as many, took thirty-eight minutes. A deployment follows only the distributions its estate runs, and there is no default.
- Whoever can produce to
security.advisoriesdecides what the platform calls vulnerable, and a plausible forged version also stops the importer asking for the real one. In the development stack that is anybody on the backbone network, which is one more reason BE-004's ACLs matter: onlyadvisory-importershould produce there.
Source evidence
Generated from docs/decisions/0028-vulnerability-intelligence-is-read-from-its-source.md at fa3bf69. Refresh the checked-in snapshot before regenerating; a normal build does not access another repository.