Research and upstream radar
The radar watches public research and upstream sources for a repository: the releases of the
projects it builds on, and the feeds where papers and announcements appear. A registry file lists
the sources, praetorctl radar collect turns one window of their entries into a Markdown digest,
and every text a source supplied is neutralised before anyone reads it. Collection keeps no state
and reads no clock: the window is an input, so consecutive runs cover time without gap or overlap.
This is the offline part of the module proposed in #818. It validates registries and collects from planted source files. Reading sources over the network, the digest issue, the scheduled workflow, the audit check, triage into the ledger and the licence and patent gate are later slices (below).
Quick start
Declare the radar in .standards.yaml. registry is optional and defaults to
.config/radar.yaml:
List the sources in that file:
---
version: 1
sources:
- id: example-project
kind: github_repo
url: https://github.com/example-org/example-project
why: Upstream of the tokenizer we port
- id: example-papers
kind: feed
url: https://example.org/papers/tokenizers.rss
why: Papers on tokenizer performance
Validate it, then collect the seven days before 7 October 2026 from a directory of planted source files:
praetorctl radar validate
praetorctl radar collect --fixture=internal/radar/testdata/acceptance --now=2026-10-07 --days=7 --out=digest.md \
--registry=internal/radar/testdata/acceptance/radar.yaml
The second command collects the acceptance fixture that ships with the engine
(internal/radar/testdata/acceptance): an organisation activity feed with a new repository and
a push, a paper feed and a releases listing. Its digest lists those four items and nothing older
(TestCollect_Positive_AcceptanceFixture).
Commands
| Command | Does | Fails when |
|---|---|---|
praetorctl radar validate [--path=.] [--registry=<file>] |
Reads the registry radar.registry names in the manifest at --path, or the --registry file, and prints the source count by kind |
the registry is invalid, or the manifest declares no radar section and no --registry is given |
praetorctl radar collect --fixture=<dir> --now=<time> --out=<file> [--days=7] [--path=.] [--registry=<file>] |
Collects the window [now - days, now) from the planted files in --fixture and writes the digest to --out |
every source failed, or an argument is missing or invalid |
--now is an RFC 3339 timestamp or a calendar date, which means midnight UTC. --days runs from 1
to 366. collect prints the read path it used and the number of entries without a readable
date. Without --fixture it fails with
network collection is not implemented yet (#818) instead of collecting nothing. Both commands
live in cmd/standardsctl/radar.go; TestRadarCommand_Negative_Refusals covers each refusal.
The registry
The registry is one YAML document, decoded strictly by radar.ParseRegistry
(internal/radar/registry.go): an unknown or misspelled key is an error, not a silently ignored
line.
| Field | Holds | Rule |
|---|---|---|
version |
the schema version | must be 1 |
sources[].id |
the name the digest uses | ASCII letters, digits, ., _, -, starting with a letter or digit; at most 64 bytes; unique without regard to case |
sources[].kind |
how the source is read | feed or github_repo |
sources[].url |
where the source lives | canonical HTTPS without credentials, query or fragment; at most 2048 bytes |
sources[].why |
what the source matters for | one line, at most 512 bytes, no @ |
A registry lists between 1 and 256 sources and is at most 256 KiB. Every problem is reported at
once, each naming its field, such as sources[2].url. TestRegistry_Negative_Refusals and
TestRegistry_Boundary_Limits in internal/radar/registry_test.go pin these rules.
The two kinds read in this version:
feedis an RSS 2.0, RSS 1.0 or Atom feed at any HTTPS address.github_repois the releases of one repository, writtenhttps://github.com/<owner>/<repository>.
The kinds github_owner, query and manual are refused with kind is not supported yet; any
other kind is refused as unknown. A URL with a query is a search, which is the query kind.
The registry has no licence field. Licence data will come from the forge licence API in a later slice. A source without licence data, such as a feed, is treated as unknown and never as permitted.
What the registry refuses
The registry and the digest name sources, never individuals. radar.ParseRegistry refuses a
source whose URL addresses one account (internal/radar/person.go):
- any page on a domain that serves profiles or person identifiers, such as
linkedin.com,x.com,orcid.org,scholar.google.comandgist.github.com; - a single path segment on a code host (
github.com,gitlab.com,codeberg.org,gitea.com,bitbucket.org,huggingface.coand its short domainhf.co), which is a user or organisation page or an account's activity feed; - any host below a hosting service that gives each account its own subdomain:
github.io,gitlab.io,substack.com,medium.com,wordpress.com,blogspot.comandbsky.social.someone.github.ioanden.someone.wordpress.comare refused; the bare domain, such ashttps://medium.com/feed/tag/tokenizers, is not; - a first path segment such as
user,users,u,people,profile,authorormemberson any host, and arXiv author listings and dblp person pages; - any path segment starting with
@or~, the shape of fediverse handles and home pages; - a
github_repowhose name equals its owner, which is the owner's profile page; - a
whythat contains@, which mentions an account or spells an address.
Each listed domain covers its subdomains, matched on whole labels: www.x.com,
mobile.twitter.com and de.linkedin.com are refused like the domain itself, while
notx.com and x.com.example.org are not. A subdomain a service uses for something other than
accounts is refused with the rest, so a single-segment feed on a code host's product subdomain,
such as https://about.gitlab.com/atom.xml, does not load; the check errs on the side of
refusing. TestRegistry_Negative_Refusals and TestRegistry_Boundary_DomainLabels list the
cases.
This check is a documented tripwire, not a guarantee. It is structural: it catches the URL shapes
listed here and nothing else, and a page about one person at an address of another shape, such
as a personal domain, passes. Review the registry like any other change. Organisations cannot be
told from users offline, so single-segment code host URLs and hosting subdomains such as
example-org.github.io are refused for both until the github_owner kind can ask the forge.
The window
radar.NewWindow (internal/radar/window.go) turns --now and --days into the half-open UTC
interval [since, now), with since exactly days × 24 hours before now. An entry published
at since belongs to the window; one published at now belongs to the next. An entry that
carries only a date is placed by date: it belongs to the window whose since date is on or before
it and whose now date is after it. Two runs whose --now values are one window apart therefore
partition time and dates exactly (TestWindow_Boundary_PartitionAndLimits).
Collecting from fixtures
--fixture names a directory holding one file per source, named after its id:
| Kind | File | Content |
|---|---|---|
feed |
<id>.xml |
the feed document |
github_repo |
<id>.json |
the JSON the GitHub REST API returns for GET /repos/{owner}/{repo}/releases |
A missing or unreadable file fails that source, as an unreachable source would. The readers treat
every document as untrusted (internal/radar/feed.go, internal/radar/release.go):
- a feed with a document type or entity declaration is refused;
- a feed is at most 4 MiB, nests at most 32 elements and carries at most 5000 entries; a releases listing is at most 4 MiB and 1000 releases. A larger document fails rather than being cut, so the digest never presents part of a source as all of it;
- malformed XML or JSON, a document that is not a feed and the API's error object are failures, never an empty source;
- a draft release is left out; an entry or release without a readable date is counted and named in the digest, because no window can place it;
- a feed date is read the same way on every host, and only in three forms: an RFC 3339 date
(
2026-10-01, placed by date), an RFC 3339 timestamp, and an RFC 822 or RFC 1123 date whose zone is a numeric offset or a name in one table. The table holds the RFC 822 zone names with a fixed meaning (UT,GMT,Zand the US zonesESTtoPDT), at the offsets RFC 5322 section 4.3 gives them, andUTC, which Go'stime.RFC1123writes. Any other form leaves the entry undated: another zone name, such asCESTor a military letter other thanZ, because placing it would rest on a guess or on the host's own zone, and other spellings such as RFC 850 dates, asctime dates or a colon in an RFC 822 offset. A two-digit year takes the RFC 5322 century:00to49are 20xx,50to99are 19xx.TestParseFeed_Positive_RFC822Zones,TestParseFeed_Negative_UnresolvedZonesandTestParseFeed_Boundary_TwoDigitYearrun every case under four local zones.
The digest
radar.Render (internal/radar/digest.go) writes the digest:
# Radar digest 2026-09-30 to 2026-10-07
Window: 2026-09-30T00:00:00Z <= t < 2026-10-07T00:00:00Z (UTC). Sources read: 1, failed: 1.
## example-project (github_repo)
Source: <https://github.com/example-org/example-project>
- 2026-10-06 [v2.0.0](https://github.com/example-org/example-project/releases/tag/v2.0.0)
## Failed sources
- example-papers: fixture example-papers.xml is missing
- Each source with something new gets a section, in registry order, newest item first. A section lists at most 50 items and counts the rest.
- Entries without a readable date are counted in their source's section, so only on a run where
that source has something new. Without state the collector cannot tell a new undated entry from
one an earlier digest counted, so undated entries alone add no section;
collectprints their total on standard output instead (TestCollect_Boundary_UndatedOnlyBesideNewItems). - When nothing is new and no source failed, the digest is an empty file, whatever undated entries the sources hold.
- Every failed source is named with its reason. A planted file that cannot be read is named by
its file name and a fixed reason, such as
is missingorexceeds the read limit, never by the local path or a link target (TestFixtureReader_Negative_PathFreeReasons). The run exits non-zero only when every source failed, and it still writes the digest that names them.
Titles and failure reasons are untrusted. radar.Neutralize reduces each to one line of inert
Markdown, capped at 200 runes: it decodes HTML entities, drops control and invisible format
characters, removes @ so no account is mentioned, breaks issue references (#12, GH-12) so
none links, replaces | so no table breaks, defangs URLs and escapes the Markdown punctuation
that opens emphasis, code, links, images or HTML. It removes only well-formed tags of HTML
elements (internal/radar/html.go): the element names of the WHATWG HTML index, math, svg
and obsolete presentational elements such as font, with attributes written name=value. Every other < and > stays text and is escaped, so a paper title such as
Speedup for n < 1000 and m > 5 in a<b keeps every word (TestNeutralize_Positive_Table). An item link renders only when
it is a plain http or https address; a link to an issue, pull request or discussion is printed
as code so the digest does not cross-reference it. TestNeutralize_Positive_Table lists each
case.
Not built yet
These parts of #818 follow in later slices:
- reading feeds and GitHub releases over the network, with per-source timeouts and byte caps;
- publishing the digest to one forge issue per window, beside the
--outfile; - the scheduled workflow template and an audit check that a declared radar has a registry that validates and a workflow that runs;
- the triage procedure for an agent lane, which lands outcomes in the ledger;
- the licence and patent gate, with licence data from the forge licence API.