Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

boast

boast gathers impact and reach metrics for a piece of research software (and/or its associated paper) from across code hosts, package registries, and citation/attention databases, so you can make statements backed by evidence about a tool’s impact — the kind you make in a grant proposal.

Point it at a Project — identified by any of a code repository, distribution packages, and/or a paper — and it gathers reach Metrics across four Categories (Code, Downloads, Citations, Attention) from a curated set of pluggable Providers, records them in a durable, timestamped Snapshot with full provenance, and renders Reports for grant writing (terminal, Markdown, and an automatically written prose sentence).

Reproducibility and honesty are first principles: dated, attributable Snapshots you can commit and re-render, metrics that are never silently coerced to zero, and totals that never mix incompatible time windows.

boast about samtools/samtools

Getting started

Install

Homebrew (macOS/Linux)

brew install mbhall88/tap/boast

Shell script (macOS/Linux)

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/mbhall88/boast/releases/latest/download/boast-installer.sh | sh

PowerShell (Windows)

powershell -ExecutionPolicy Bypass -c "irm https://github.com/mbhall88/boast/releases/latest/download/boast-installer.ps1 | iex"

Docker

docker run --rm ghcr.io/mbhall88/boast:latest about 10.1234/journal.xyz

cargo

cargo install boast --locked

From source

git clone https://github.com/mbhall88/boast
cd boast
cargo install --path . --locked

Prebuilt binaries (Linux x86_64/aarch64/armv7 — all statically linked, musl — and macOS x86_64/aarch64, Windows x86_64) are attached to every GitHub Release.

Your first report

Point boast about at anything with a DOI, and it prints a Report straight to your terminal — no config, no Manifest, no account:

boast about 10.1371/journal.pbio.1002195

Output:

boast 0.1.1 — as of 2026-08-03T05:43:11Z

━━ doi:10.1371/journal.pbio.1002195 ━━
"Big Data: Astronomical or Genomical?" — Zachary D. Stephens et al., PLOS Biology, 2015
── Citations ──
  citations              1426  all-time                 openalex
  fwci                  59.95  all-time                 openalex  field-weighted citation impact; 1.0 = world average
  citation_percentile   99.96  all-time                 openalex  top 1% in its field, year, and type
  citations              1166  all-time                 crossref  times referenced, per Crossref
  citations              1289  all-time                 dimensions
  recent_citations        165  last two calendar years  dimensions  resets each 1 January; not a rolling 24-month window
  fcr                  116.83  all-time                 dimensions  Field Citation Ratio; 1.0 = world average for the field and year
  rcr                   15.96  all-time                 dimensions  Relative Citation Ratio; 1.0 = NIH-funded benchmark
  citations               581  all-time                 europe_pmc  citation count from Europe PMC
── Attention ──
  open_access         gold  all-time  openalex  OpenAlex open-access status; "closed" means no open-access copy found
  wikipedia_mentions     0  all-time  wikipedia
  altmetric            N/A  Altmetric attention data not collected: no Details Page API key (ALTMETRIC_KEY)

── Notices ──
  This data has been sourced via the Dimensions Metrics API, use of which is subject to the terms at https://dimensions.ai/policies/terms/metrics/. Any use by an unregistered organization is not authorized. Please contact info@dimensions.ai for further information.
  English Wikipedia full-text search hits for this DOI; other-language Wikipedias are not counted

Measure a repository

A repository can be measured without a DOI or package. Pass either owner/name or the full GitHub URL; both forms resolve to the same repository identity:

boast about --repo mbhall88/rasusa
boast about --repo https://github.com/mbhall88/rasusa

Alongside the repository’s Code metrics, the report includes independent indexed-search estimates from OpenAlex and Europe PMC under Attention:

━━ github:mbhall88/rasusa ━━
── Attention ──
  mentions  16  all-time  openalex
  mentions  12  all-time  europe_pmc

These are not formal citation counts or verified literal URL occurrences. They are coverage-limited full-text search estimates; self-mentions count, and a preprint and its published version can count separately. The two providers are shown side by side and are never summed. Europe PMC is concentrated in life-sciences literature.

If the piece of software also has a code repository and/or is published on a package registry, tell boast about those too. The repository adds Code and Attention, a package adds Downloads, and a DOI adds paper Citations:

boast about --repo samtools/samtools \
            --package conda:bioconda/samtools \
            10.1093/gigascience/giab008

Now the Report gains Code and Attention sections for the repo, a Downloads section for the package, and a Downloads Rollup combining the two channels that share a compatible Window — on top of everything the bare DOI already produced above:

━━ github:samtools/samtools ━━
── Code ──
  stars                 1934  all-time  github
  forks                  613  all-time  github
  watchers                94  all-time  github  users watching the repo (subscribers)
  repo_age_years       14.40  all-time  github  since 2012-03-09
  contributors            108  all-time  github
  release_downloads  2156386  all-time  github  summed across release assets

── Attention ──
  mentions                 407  all-time  openalex
  mentions                1104  all-time  europe_pmc

━━ conda:bioconda/samtools ━━
── Downloads ──
  downloads  9032484  all-time  bioconda

═══ Downloads Rollup (derived — see channels above) ═══
  11188870 all-time = github:samtools/samtools (2156386) + conda:bioconda/samtools (9032484)

Every run above already wrote a Snapshot — boast about saves one to snapshots/ by default (pass --no-save to skip that and only print). That first Snapshot is the start of a history you can diff against later:

boast render snapshots/<the-file-it-just-wrote>.json --format markdown

Output (the raw Markdown source — this is what you’d commit or paste into a report):

# boast Report

_boast 0.1.1 — as of 2026-08-03T05:43:44Z_

## doi:10.1371/journal.pbio.1002195

"Big Data: Astronomical or Genomical?" — Zachary D. Stephens et al., PLOS Biology, 2015

### Citations

| Metric | Value | Window | Provider | Detail |
| --- | --- | --- | --- | --- |
| citations | 1426 | all-time | [openalex](https://api.openalex.org/works/doi:10.1371/journal.pbio.1002195) |  |
| fwci | 59.95 | all-time | [openalex](https://api.openalex.org/works/doi:10.1371/journal.pbio.1002195) | field-weighted citation impact; 1.0 = world average |
...

From here, see Concepts for the vocabulary, or jump straight to a Guide that matches your situation.

Concepts

The glossary is shared by boast’s CLI, code, and documentation, so the same terms mean the same thing everywhere. This page is generated from the canonical copy in the repo, CONTEXT.md.

Project: The central entity — a single piece of research work that may link to a code repository, one or more distribution packages, and one or more papers. A bare paper lookup (e.g. by DOI) is just a Project whose only linked identity is a paper. Avoid: Tool (too narrow — a Project may be paper-only), Package, Repo

Metric: A single measured quantity of reach for a Project. Every Metric carries: a value, the Provider it came from, the Identity it describes, an as-of timestamp (when it was fetched), and a coverage window (see Window). A raw number with no window and no as-of is not a Metric. Avoid: Stat, statistic, number

Outcome: The result of one Provider×Identity fetch, always exactly one of: Value (a real number), NotApplicable (the Identity legitimately has no presence on that channel — shown as N/A, never 0), or Failed (a transient error: rate limit, timeout, 5xx — the number is obtainable but this attempt didn’t get it). What separates the last two is whether retrying could ever help: a channel boast has no credential to read, or no key configured for, is NotApplicable however it announced itself (ADR-0010). Snapshots record the Outcome explicitly; NotApplicable and Failed are never coerced to 0. Avoid: Status, state, error

Window: The span of time a Metric’s value covers. Either cumulative (all-time, e.g. crates.io total downloads, GitHub release download_count), trailing (a rolling period, e.g. Homebrew 365-day installs, PyPI last-month), or periodic (a named bucket, e.g. OpenAlex citations in year 2023). Two Metrics may only be summed if their Windows are compatible. Avoid: Period, timeframe, range

Manifest: An optional file listing one or more Projects, their Identities, and any chosen or prioritised Cohort topics for repeatable or batch runs. Never required: a single Project can be given inline via CLI flags, and a bare paper (DOI/PMID) needs neither. The tool can generate a Manifest from a run, so it is a save-file, not a hand-authored prerequisite. Holds no secrets. Avoid: Config, spec, input file

Snapshot: The primary durable artifact: a timestamped, machine-readable record of every Metric fetched in one run, each with full provenance (Provider, Identity, value, as-of, Window, source URL/response). Snapshots are append-only; a human-readable Report is rendered from a Snapshot, and Snapshots are diffed to show change over time. Avoid: Run, result, output, cache

Report: A human-readable rendering of one or more Snapshots, always derived from Snapshots and never fetching data itself. v1 renderers: a terminal table (default), Markdown (primary saved artifact), and a prose snippet (an automatically written sentence for grant writing). HTML (with over-time charts) and CSV come later. Avoid: Output, summary, document

Notice: A Provider’s licence or terms text, recorded on the Metric it accompanies and shown once per Report in a de-duplicated footer, so attribution travels with the artifact rather than with the terminal session that produced it (ADR-0005). De-duplicated by exact text across every Provider and Identity, because the same boilerplate legitimately repeats on each one. Avoid: Disclaimer, licence blurb, footnote, attribution

Provider Note: An operational explanation carried by a Provider×Identity Outcome — why a fetch yielded no value, failed, or returned only part of the requested data (“no API key configured”, “rate limited after five of twenty Cohort ranks”). Not a Notice: it describes collection status rather than the terms behind a Metric, so it appears in its own Report section keyed by Provider and Outcome kind (ADR-0008). Avoid: Error message, warning, notice, detail

Cohort: The set of repositories a Project’s repo is ranked within, defined by a GitHub topic (e.g. all repos tagged rna-seq, sorted by stars). A Project may be ranked within one or more Cohorts read from the repo’s declared topics, selected explicitly, or prioritised ahead of the remaining declared topics. A Cohort ranking is always reported with its topic named and a disclaimer that GitHub topics are inconsistently applied. Avoid: Peers, competitors, similar tools, category

Rollup: A derived Metric produced by combining compatible Metrics — e.g. a total downloads value summed across channels. A Rollup must name every Metric it includes and their shared Window; it never silently mixes incompatible Windows or channels. Avoid: Total, sum, aggregate

Category: The family a Metric belongs to, used to group the Report. Four in v1: Code (stars, forks, contributors, release downloads, cohort rank…), Downloads (per-channel package/install counts + Rollup), Citations (counts + field-normalized FWCI/percentile/FCR/RCR), and Attention (open-access status, Wikipedia mentions, and indexed scholarly repository mentions keyless by default; full news/blog/policy/patent/social breakdown via Altmetric when a key is present). Avoid: Kind, group, type, section

Provider: A component for one source that, given an Identity, fetches zero or more Metrics from one external service (GitHub, Bioconda, OpenAlex, …). Providers are pluggable; the system ships a curated default set. Avoid: Source, backend, connector, adapter

Identity: One external handle a Project links to, of a known kind: a code repository, a distribution package (with its registry), or a paper (DOI / PubMed ID). A Provider consumes Identities of the kinds it understands. An Identity always names a piece of work, never a person: a researcher identifier (an ORCID iD) is not an Identity but an input that expands into a set of them (see ADR-0006). Avoid: Handle, reference, link, target

Guides

Worked examples for the shapes a Project usually takes, plus automating the whole thing in CI.

Measuring a paper

The smallest possible Project: just a paper, identified by DOI or PubMed ID, with no code repository or package attached. This is the right shape when you’re reporting on a publication itself rather than a specific tool.

Repository scholarly mentions are a separate measurement. Add a repository identity with --repo when you want OpenAlex and Europe PMC to search for the repository as well; a DOI-only Project’s Attention rows describe the paper, not its code repository.

boast about 10.1371/journal.pbio.1002195

Output — Citations and Attention Metrics only. Code and Downloads have nothing to attach to without a repository or package, so they’re omitted entirely rather than shown as zero (see ADR-0002; the full transcript, including what a Code/Downloads section looks like once a repo and package are added, is in Getting started):

boast 0.1.1 — as of 2026-08-03T05:43:11Z

━━ doi:10.1371/journal.pbio.1002195 ━━
"Big Data: Astronomical or Genomical?" — Zachary D. Stephens et al., PLOS Biology, 2015
── Citations ──
  citations              1426  all-time                 openalex
  ...

A bare PubMed ID works the same way:

boast about pmid:26151137

Same paper, different identifier — but not quite the same output. A few Providers (Crossref, Dimensions) only look papers up by DOI, so a PMID-only Project sees fewer of them than a DOI one does:

boast 0.1.1 — as of 2026-08-03T05:46:47Z

━━ pmid:26151137 ━━
── Citations ──
  citations             1426  all-time  openalex
  fwci                 59.95  all-time  openalex  field-weighted citation impact; 1.0 = world average
  citation_percentile  99.96  all-time  openalex  top 1% in its field, year, and type
  citations              581  all-time  europe_pmc  citation count from Europe PMC

Each run already writes a Snapshot to snapshots/ (pass --no-save to only print). See Diffing the history to compare one Snapshot against a later run.

A tool with a repository and packages

The common case for research software: a GitHub repository, one or more package registries, and (usually) a paper describing it. Giving boast all three means every Category — Code, Downloads, Citations, Attention — has something to report on.

boast about --repo samtools/samtools \
            --package conda:bioconda/samtools \
            10.1093/gigascience/giab008

--package is repeatable — list every registry the tool is published on. samtools is also on Homebrew, so:

boast about --repo samtools/samtools \
            --package conda:bioconda/samtools \
            --package homebrew:samtools \
            10.1093/gigascience/giab008

Count scholarly mentions for a repository

You can query a repository on its own:

boast about --repo mbhall88/rasusa

--repo accepts either owner/name or a full GitHub URL. The repository identity is normalised before querying, and boast searches the quoted host-qualified phrase github.com/owner/name in both OpenAlex and Europe PMC. It does not add a bare owner/name alias, because punctuation-aware matching cannot make that search literal.

The relevant part of the report looks like this:

━━ github:mbhall88/rasusa ━━
── Attention ──
  mentions  16  all-time  openalex
  mentions  12  all-time  europe_pmc

These values are indexed full-text search estimates, not formal citation counts or verified literal URL occurrences. Coverage is partial; Europe PMC is concentrated in life-sciences literature; self-mentions count; and article/preprint versions may be counted separately. The providers are independent and their values are never summed.

Output (Code, indexed scholarly mentions under Attention, Downloads per channel, and a Downloads Rollup — Citations are the same shape shown in Getting started, so only the sections that are new here are shown):

━━ github:samtools/samtools ━━
── Code ──
  stars                 1934  all-time  github
  forks                  613  all-time  github
  watchers                94  all-time  github  users watching the repo (subscribers)
  repo_age_years       14.40  all-time  github  since 2012-03-09
  contributors            108  all-time  github
  release_downloads  2156386  all-time  github  summed across release assets

── Attention ──
  mentions                 407  all-time  openalex
  mentions                1104  all-time  europe_pmc

━━ conda:bioconda/samtools ━━
── Downloads ──
  downloads  9032484  all-time  bioconda

━━ homebrew:samtools ━━
── Downloads ──
  downloads_30d    503  last 30 days   homebrew
  downloads_90d   1169  last 90 days   homebrew
  downloads_365d  5566  last 365 days  homebrew

═══ Downloads Rollup (derived — see channels above) ═══
  11188870 all-time = github:samtools/samtools (2156386) + conda:bioconda/samtools (9032484)

The Attention section reports independent indexed-search estimates from OpenAlex and Europe PMC for the repository’s host-qualified URL. They are shown side by side and are not summed: each service has different coverage, and the counts can include self-mentions and separate article/preprint versions. The Notice attached to each row explains those limits.

Homebrew’s own Metrics don’t join the Rollup — they’re all trailing Windows (30/90/365 day), and a Rollup can only sum Metrics that share a compatible Window (see Concepts, Rollup and Window); mixing a trailing count in with two all-time counts would misrepresent the total, so it stays out.

Run boast providers to see the full registry of Providers, which Category each serves, and which package registries they cover.

Container images

Many research tools also ship as a container, on two registries boast covers:

  • Docker Hub, as docker:namespace/name. Official images live under library, so ubuntu is docker:library/ubuntu.
  • Quay.io, as quay:namespace/name. This is where Bioconda’s auto-built per-package containers live, so a bioconda recipe gets you quay:biocontainers/<pkg> for free.

If you package for Bioconda, reach for Quay. The biocontainers/ organisation on Docker Hub is an older, hand-curated set — its samtools image was last pushed in 2019 — while quay.io/biocontainers is what the build system actually publishes to, and where the traffic actually goes:

boast about --package quay:biocontainers/samtools \
            --package docker:biocontainers/samtools \
            --package conda:bioconda/samtools

Captured on a later day than the run above, so bioconda’s count has moved on — which is the point of Snapshots being dated:

━━ quay:biocontainers/samtools ━━
── Downloads ──
  pulls  1786502  last 92 days  quay

━━ docker:biocontainers/samtools ━━
── Downloads ──
  downloads  596337  all-time  dockerhub

━━ conda:bioconda/samtools ━━
── Downloads ──
  downloads  9055038  all-time  bioconda

═══ Downloads Rollup (derived — see channels above) ═══
  9651375 all-time = docker:biocontainers/samtools (596337) + conda:bioconda/samtools (9055038)

── Notices ──
  Quay.io pull counts record image fetches by machines, not installs by people, and CI re-pulls dominate for a biocontainer; Quay publishes only a rolling daily series, so this is not an all-time total
  Docker Hub pull counts record image fetches by machines, not installs by people: CI re-pulls and mirror warming inflate the figure, and it never resets

Note the scale: three months on Quay is triple the lifetime total of the stale Docker Hub image.

The two registries land in the Rollup differently, and it’s the Window that decides — not the fact that both count container pulls:

  • Docker Hub publishes an all-time pull_count, so it shares a cumulative Window with the conda and crates.io counts and joins the Rollup.
  • Quay publishes only a rolling daily series, never a lifetime total, so its figure is trailing. boast reports the window it actually measured — last 92 days above, read off the length of the series Quay returned rather than assumed — and a trailing count can’t be summed with all-time ones, so it stays out of the all-time Rollup.

To be precise about the rule: what’s excluded is mixing incompatible Windows, not trailing Windows as such. Metrics sharing an exactly-equal trailing Window do roll up together — a Homebrew 30-day install count and a PyPI 30-day download count form their own last 30 days Rollup, separate from the all-time one. Quay’s ~92-day window simply has nothing else to pair with today.

Read every container number with its Notice in mind: a pull is a much weaker signal than an install. Both registries count image fetches by machines, so CI re-runs and mirror warming land in the same figure, and Docker Hub’s counter never resets — docker:library/ubuntu sits near ten billion. This is why the Rollup always names each channel and its own value: the total is only ever as meaningful as the channels you can see underneath it.

One quirk worth knowing about Quay: it answers a lookup for an image you can’t see with “requires authentication” rather than “not found”, and it does that identically whether the image is private or simply doesn’t exist. boast reports that as N/A with a note saying so, not as a failed fetch — no amount of retrying will turn it into a number, so it doesn’t make your Snapshot partial.

GitHub’s container registry (ghcr.io) has no equivalent Provider, because GHCR publishes no pull statistics — neither the OCI registry API nor GitHub’s Packages API exposes a download count. An image hosted only there can’t contribute to Downloads at all.

Save the identifiers for next time

Re-typing --repo/--package/the DOI on every run gets old fast. --save writes a Manifest capturing exactly the identities (and --topic, if given) used in this run:

boast about --repo samtools/samtools \
            --package conda:bioconda/samtools \
            --save manifest.toml \
            10.1093/gigascience/giab008

From then on:

boast about manifest.toml

You can also build a Manifest up front, without fetching anything, via boast init (same flags as about):

boast init --repo samtools/samtools --package conda:bioconda/samtools \
           --package homebrew:samtools -o manifest.toml 10.1093/gigascience/giab008

manifest.toml now contains, offline, with nothing fetched:

[[project]]
identities = [
    "doi:10.1093/gigascience/giab008",
    "github:samtools/samtools",
    "conda:bioconda/samtools",
    "homebrew:samtools",
]

Ranking within a cohort

If the repo is tagged with a GitHub topic (or you want to force one), boast can report where it ranks by stars among every repo sharing that topic:

boast about --repo samtools/samtools --topic bioinformatics 10.1093/gigascience/giab008

The Code section gains a cohort_rank row, and a matching disclaimer appears in Notices:

── Code ──
  ...
  cohort_rank (bioinformatics)       16  all-time  github

── Notices ──
  #16 of 15275 repos tagged 'bioinformatics'; GitHub topics are inconsistently applied

Omit --topic and boast ranks within whatever topics the repo has actually declared on GitHub — see the Cohort entry in Concepts for the disclaimer this ranking always carries.

A researcher’s whole publication record

Rather than measuring one tool, you can expand a researcher’s ORCID iD into one Project per work in their public record — every paper they’ve published, each as its own bare-paper Project. An ORCID iD identifies a person, never a piece of work, so it isn’t an Identity itself; it’s an input that expands into a set of them (see ADR-0006).

boast init --orcid 0000-0002-1825-0097 -o manifest.toml

(0000-0002-1825-0097 is ORCID’s own public sandbox/demo account — Josiah Carberry, a fictional professor ORCID maintains specifically for testing against the real API — used here instead of a real researcher’s iD.)

This performs a real network fetch against the ORCID public API (unlike the rest of init, which only writes a file from what you already gave it) and writes one [[project]] entry per work that has a DOI or PMID. Works with neither are skipped — boast has no Provider that can measure them — and a summary of how many were found, kept, and skipped is printed to stderr:

 WARN orcid:0000-0002-1825-0097: 6 works in record; 6 have a DOI/PMID and will be written to manifest.toml, 0 were skipped (no DOI or PMID — not measurable).
 WARN running `boast about` over 6 works ≈ 36 requests across 6 Providers
Manifest written to manifest.toml

manifest.toml (header comment plus the first two of six [[project]] entries):

# Generated by `boast init --orcid 0000-0002-1825-0097` on 2026-08-03 — 6 works.
# 6 written below; 0 skipped (no DOI/PMID).
# Re-run with --include-unidentified to list the skipped works for completion.
# Each remaining work costs ~6 API requests when you run `boast about`.
[[project]]
identities = ["doi:10.5555/12345680"]

[[project]]
identities = ["doi:10.5555/666655554444"]
...

--orcid accepts a bare iD, an orcid:-prefixed one, or a full orcid.org URL, and is repeatable if you want one Manifest covering several researchers:

boast init --orcid 0000-0002-1825-0097 --orcid 0000-0001-2345-6789 -o manifest.toml

It’s exclusive with every other identity source (positionals, --repo, --package, --from-file) — an ORCID expansion has no defensible answer to “which of these works does that repo belong to?”, so mixing them is rejected rather than guessed at.

Works without a DOI or PMID

Most ORCID records carry works boast can’t measure (books, datasets, talks — anything without a DOI or PMID). By default these are silently dropped. Pass --include-unidentified to list them instead, as commented-out [[project]] blocks you can fill in by hand if one of them does have an identifier ORCID just didn’t capture:

boast init --orcid 0000-0002-1825-0097 --include-unidentified -o manifest.toml

The Carberry demo record above happens to have zero unidentified works, so there’s no real run to show for it here — but on a record that has some, each appears (title, year, and work type, straight from the ORCID record) as a commented-out block instead of being silently dropped:

# ─── 1 work with no DOI or PMID in your ORCID record ───
# These can't be measured as-is. If you know the DOI, uncomment the block and
# fill it in — and consider adding it to your ORCID record so it's there next time.
#
# "Some Conference Talk" (2019, lecture-speech)
# [[project]]
# identities = ["doi:"]

Running it

Once you have the Manifest:

boast about manifest.toml

writes one Snapshot per Project. Because that’s usually a lot of Projects for one researcher, the CI automation guide — built for a single Project Manifest — doesn’t directly cover this case; see its “Scope” section for the reason and the workaround.

Snapshot history in CI

A copy-paste GitHub Actions workflow for your own repo (not boast’s) that runs boast about on a schedule, commits the resulting Snapshot, and keeps a rolling Markdown Report up to date — so boast diff has real history to compare against with zero manual work.

This is deliberately YAML you copy and edit, not a published Action or reusable workflow. Everyone adopting it customises something (the Manifest path, the schedule, which identities it covers), and a template invites that where a versioned Action would fight it. When a scheduled run fails at 3am it’s almost always a rate limit or a network blip — plain YAML has no indirection to dig through to find out why.

Prerequisites

A Manifest listing the single Project you want to track (see “Scope” below), committed to the repo. Build one once with:

boast init --repo owner/name --package crates:name 10.1234/journal.xyz

Commit the resulting manifest.toml. (boast init --orcid <ORCID iD> builds a Manifest too, but typically lists many Projects — one per publication — which this template’s report step doesn’t cover; see “Scope” below before using it here.)

The workflow

Save as .github/workflows/impact-snapshot.yml:

name: Impact snapshot

on:
  schedule:
    # 03:00 UTC on the 1st of every month. Citations, downloads, and stars
    # move slowly — weekly mostly adds diff noise and repo churn for near-
    # identical numbers. Monthly gives a clean twelve-points-a-year series,
    # matching how these figures actually get quoted ("as of March 2026").
    - cron: '0 3 1 * *'
  workflow_dispatch: {} # lets you trigger a run by hand to test the workflow

permissions:
  contents: write # needed to commit and push the Snapshot + report

jobs:
  snapshot:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install boast
        run: |
          set -euo pipefail
          curl --proto '=https' --tlsv1.2 -LsSf https://github.com/mbhall88/boast/releases/latest/download/boast-installer.sh | sh
          echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"

      # Deliberately NOT `continue-on-error` and NOT `|| true`: `boast about`
      # exits 1 if any Provider fetch failed (rate limit, timeout, ...), and
      # that should still turn this job red so a real, persistent problem
      # doesn't go unnoticed. What it must NOT do is stop the Snapshot from
      # being written or committed — a partial Snapshot is honest data (a
      # `Failed` Outcome is recorded, never dropped), and skipping the commit
      # would additionally punch a silent gap in the history that a later
      # `diff` couldn't explain.
      - name: Run boast about
        run: boast about manifest.toml --snapshot-dir snapshots
        env:
          # Auto-provided by Actions — raises GitHub's API rate limit for the
          # Code category. No repo secret needed for this one.
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Optional: only set this repo secret if you have an Altmetric
          # Details Page API key. Without it, Altmetric metrics are reported
          # as not-applicable rather than fetched — everything else still runs.
          ALTMETRIC_KEY: ${{ secrets.ALTMETRIC_KEY }}

      # `if: always()` so this and the commit step below still run even when
      # the step above exited non-zero. `boast render` itself also exits 1
      # for a Snapshot recording a `Failed` Outcome, so on a partial failure
      # this step goes red too, alongside `boast about` above — expected,
      # not a second problem: IMPACT.md is still written correctly (the
      # FAILED row included, per ADR-0002) and still gets committed below.
      - name: Regenerate the rolling report
        if: always()
        run: |
          set -euo pipefail
          newest=$(find snapshots -maxdepth 1 -name '*.json' | sort | tail -n1)
          boast render "$newest" --format markdown > IMPACT.md

      - name: Commit snapshot and report
        if: always()
        run: |
          set -euo pipefail
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add snapshots IMPACT.md
          git diff --cached --quiet && exit 0
          git commit -m "chore: monthly impact snapshot"
          git push

The knobs

  • Schedule — the cron: line. '0 3 1 * *' is monthly; tighten it (e.g. '0 3 * * 1' for weekly on Mondays) if your numbers move fast enough to be worth the extra diff noise and repo churn.
  • Manifest pathmanifest.toml in the boast about step. Point it at wherever your Manifest lives if it isn’t at the repo root.
  • Snapshot directory--snapshot-dir snapshots (boast’s own default). Snapshots are named by boast itself from the run timestamp (YYYYMMDDTHHMMSSZ.json when driven by bare identifiers) — already unique, already lexically sortable, already carrying the as-of time. When a Manifest drives the run, boast also suffixes the filename with the Project’s own identity (e.g. 20260301T030001Z-doi-10.1234-journal.xyz.json) so that multiple Projects sharing one Manifest never collide — still lexically sortable, just not a bare timestamp.
  • Report filenameIMPACT.md, overwritten every run rather than timestamped. It’s offline and deterministic (boast render never touches the network — see ADR-0001), so regenerating it is nearly free, and a stable filename gives you one current page to link to from your README instead of hundreds of near-identical Markdown files accumulating over the years.

Make sure snapshots/ (and IMPACT.md) aren’t excluded by your repo’s .gitignore — it’s an easy thing to have picked up from a template that assumed the opposite.

Why commit Snapshots instead of uploading them as workflow artifacts? Artifacts expire (90-day retention by default) and would silently evaporate the accumulating history that’s the entire point of this workflow — and you can’t diff two of them without downloading both by hand first. A committed file has neither problem.

One Project per Manifest

This template’s “Regenerate the rolling report” step renders whichever Snapshot file sorts last — correct as long as your Manifest lists a single [[project]] (the common case: tracking your own tool’s reach, which is why the Prerequisites example above builds a one Project Manifest). A Manifest listing several Projects makes boast about manifest.toml write one Snapshot file per Project on every run; the “newest” pick above then only covers whichever Project’s file happens to sort last, silently leaving the others out of IMPACT.md. If you need one report covering several Projects, render each Project’s own newest Snapshot into its own file (e.g. loop over the distinct filename suffixes) rather than trying to squeeze them into one IMPACT.md, or run this workflow once per Project against separate single Project Manifests.

Diffing the history once you have it

Once you’ve got two or more committed Snapshots, compare any pair directly (filenames carry the Project’s own identity suffix, per “The knobs” above):

boast diff snapshots/20260301T030001Z-doi-10.1234-journal.xyz.json \
           snapshots/20260401T030001Z-doi-10.1234-journal.xyz.json

Keys as repo secrets

  • GITHUB_TOKEN — the workflow above uses the token Actions injects automatically (secrets.GITHUB_TOKEN); you don’t need to create anything. It only raises the rate limit for GitHub repo metrics — omitting it still works, just at the unauthenticated 60 requests/hour limit.
  • ALTMETRIC_KEY — optional, and only relevant if you have an Altmetric Details Page API key (not an Explorer key — they’re different products with different credentials). Add it under Settings → Secrets and variables → Actions → New repository secret on your repo. Without it, Attention category Altmetric metrics report as not-applicable; every other Provider is unaffected.

Run boast providers to see the full, current list of which Providers need which key.

Providers reference

Every Provider in boast’s default registry, which Category it serves, whether it’s enabled by default, and what environment variable (if any) it needs a key in. This page is generated from boast providers — the same command you can run yourself to check what’s obtainable before running about.

PROVIDER    CATEGORY   DEFAULT  KEY
github      Code       yes      optional: GITHUB_TOKEN (not set)
crates.io   Downloads  yes      none
anaconda    Downloads  yes      none
pypi        Downloads  yes      none
homebrew    Downloads  yes      none
dockerhub   Downloads  yes      none
quay        Downloads  yes      none
openalex    Citations  yes      none
crossref    Citations  yes      none
dimensions  Citations  yes      none
europe_pmc  Citations  yes      none
wikipedia   Attention  yes      none
altmetric   Attention  yes      required: ALTMETRIC_KEY (not set)

An optional key raises a rate limit or unlocks extra Metrics but isn’t required; a required key means that Provider reports every Metric as not-applicable until it’s set (never as zero — see ADR-0002).

OpenAlex and Europe PMC also accept GitHub repository identities. For a repo they add an independent mentions Metric under Attention, based on each service’s indexed full-text search. These are coverage-limited estimates, not formal citation counts or verified literal URL occurrences; the values are shown side by side and are never summed.

CLI reference

Every subcommand and flag, straight from --help.

boast

Gather reach and impact metrics for a research tool or paper into dated, quotable snapshots.

Usage: boast [OPTIONS] <COMMAND>

Commands:
  about      Fetch metrics for a Project, write a Snapshot, and print a report. A single `.toml` positional (see `boast init`) is loaded as a Manifest instead, running every Project it lists
  render     Render a stored Snapshot as Markdown or prose. Never touches the network (ADR-0001) — offline and deterministic for a given Snapshot
  diff       Compare two stored Snapshots and report the change in each shared Metric. Never touches the network (ADR-0001)
  providers  List the registered Providers: Category, default-enabled status, and key requirement. Never touches the network
  init       Write a Manifest TOML file from identifiers, without fetching — unless `--orcid` expands a researcher's record, which does (see its own help)
  help       Print this message or the help of the given subcommand(s)

Options:
  -v, --verbose...  Increase logging verbosity (-v info, -vv debug, -vvv trace)
  -q, --quiet       Silence all logging except errors
  -h, --help        Print help
  -V, --version     Print version

boast about

Fetch metrics for a Project, write a Snapshot, and print a report. A single `.toml` positional (see `boast init`) is loaded as a Manifest instead, running every Project it lists

Usage: boast about [OPTIONS] [IDENTIFIER]...

Arguments:
  [IDENTIFIER]...  Identifiers: a DOI, doi.org URL, `pmid:12345678`, a github.com URL, `owner/name`, or a package as `registry:name` (e.g. `crates:boast`)

Options:
  -r, --repo <OWNER/NAME>        A GitHub repository as `owner/name` (alternative to a positional; repeatable)
  -p, --package <REGISTRY:NAME>  A distribution package as `registry:name`, e.g. `crates:boast` (alternative to a positional; repeatable)
  -f, --from-file <FILE>         Read identifiers from a file (one per line; `#` comments and blank lines ignored). Use `-` for stdin. Repeatable
  -t, --topic <TOPIC>            GitHub topic to rank repositories within, overriding each repo's own declared topics (see the Cohort disclaimer in the report). When the input is a Manifest, this overrides every Project's own topic too
  -d, --snapshot-dir <DIR>       Directory to write the Snapshot into [default: snapshots]
  -n, --no-save                  Print the report but do not write a Snapshot file
  -v, --verbose...               Increase logging verbosity (-v info, -vv debug, -vvv trace)
  -q, --quiet                    Silence all logging except errors
  -s, --save <FILE>              After fetching, also write a Manifest reflecting the identities (and `--topic`) used in this run, so a future run can `boast about <file>` instead of re-typing them. Not available when the input is itself a Manifest — use `boast init` to build one up front instead
  -j, --threads <N>              Maximum number of distinct hosts fetched from concurrently. Never more than one request is in flight against the *same* host no matter how high this is set (ADR-0007). Raising it past the number of hosts a Project actually touches (at most the Provider registry's size, ~13 by default) buys nothing; lower it to open fewer simultaneous connections [default: 8]
  -h, --help                     Print help

boast render

Render a stored Snapshot as Markdown or prose. Never touches the network (ADR-0001) — offline and deterministic for a given Snapshot

Usage: boast render [OPTIONS] <SNAPSHOT>

Arguments:
  <SNAPSHOT>
          Path to a Snapshot JSON file written by `boast about`

Options:
  -f, --format <FORMAT>
          Output format

          Possible values:
          - markdown: Markdown Report grouped by Category — the primary saved artifact
          - prose:    A single sentence for grant writing, summarising the headline Metrics
          
          [default: markdown]

  -v, --verbose...
          Increase logging verbosity (-v info, -vv debug, -vvv trace)

  -q, --quiet
          Silence all logging except errors

  -h, --help
          Print help (see a summary with '-h')

boast diff

Compare two stored Snapshots and report the change in each shared Metric. Never touches the network (ADR-0001)

Usage: boast diff [OPTIONS] <OLD> <NEW>

Arguments:
  <OLD>  The earlier Snapshot JSON file
  <NEW>  The later Snapshot JSON file

Options:
  -v, --verbose...  Increase logging verbosity (-v info, -vv debug, -vvv trace)
  -q, --quiet       Silence all logging except errors
  -h, --help        Print help

boast providers

List the registered Providers: Category, default-enabled status, and key requirement. Never touches the network

Usage: boast providers [OPTIONS]

Options:
  -v, --verbose...  Increase logging verbosity (-v info, -vv debug, -vvv trace)
  -q, --quiet       Silence all logging except errors
  -h, --help        Print help

boast init

Write a Manifest TOML file from identifiers, without fetching — unless `--orcid` expands a researcher's record, which does (see its own help)

Usage: boast init [OPTIONS] [IDENTIFIER]...

Arguments:
  [IDENTIFIER]...  Identifiers: a DOI, doi.org URL, `pmid:12345678`, a github.com URL, `owner/name`, or a package as `registry:name` (e.g. `crates:boast`)

Options:
  -r, --repo <OWNER/NAME>        A GitHub repository as `owner/name` (alternative to a positional; repeatable)
  -p, --package <REGISTRY:NAME>  A distribution package as `registry:name`, e.g. `crates:boast` (alternative to a positional; repeatable)
  -f, --from-file <FILE>         Read identifiers from a file (one per line; `#` comments and blank lines ignored). Use `-` for stdin. Repeatable
  -t, --topic <TOPIC>            GitHub topic to record in the Manifest for this Project's Cohort ranking
  -o, --output <FILE>            Where to write the Manifest [default: manifest.toml]
  -O, --orcid <ORCID>            Expand a researcher's ORCID iD (bare, `orcid:`-prefixed, or an orcid.org URL) into a Manifest of every work with a DOI or PMID, one Project per work (ADR-0006; repeatable). **Performs a network fetch** — unlike the rest of `init`, which is otherwise offline. Exclusive with positionals/`--repo`/`--package`/`--from-file`: an ORCID expansion has no defensible answer to "which of these works does that repo belong to?"
  -v, --verbose...               Increase logging verbosity (-v info, -vv debug, -vvv trace)
  -q, --quiet                    Silence all logging except errors
  -u, --include-unidentified     With `--orcid`, also list works with neither a DOI nor a PMID (and so were skipped) as commented-out `[[project]]` blocks you can fill in by hand. Off by default: most ORCID records carry many such works
  -h, --help                     Print help

Design decisions

boast’s architecture decision records (ADRs), unabridged and in order. Each one records a trade-off with alternatives, written down while the reasoning was fresh so it doesn’t have to be reconstructed later from code archaeology.

The canonical copies live in docs/adr/ in the repo; the pages here are generated from those files, not duplicated by hand.

For the original end-to-end design — the v1 spec, before it was broken up into ADRs — see docs/spec/0001-boast-v1.md.

Architecture centred on Snapshots

Status

accepted

Context and decision

boast produces numbers that people quote in grant proposals (“as of March 2026, 2,400 citations”). Those claims must stay defensible after the underlying metrics move on. We therefore make a durable, append-only Snapshot — not a printed report — the primary artifact of a run.

boast about fetches live and writes a timestamped, machine-readable Snapshot recording every Metric with full provenance (Provider, Identity, value, as-of timestamp, Window, source). A Report (terminal table, Markdown, prose, HTML, CSV) is always rendered from a Snapshot and never fetches data itself. render and diff operate purely offline on stored Snapshots; growth-over-time comes from diffing Snapshots, so no database is needed — just committable files.

Considered options

  • Stateless print-and-forget. Simplest, but nothing is reproducible after the fact: re-running next month silently yields different numbers with no record of what was originally quoted. Rejected — reproducibility is the whole point.
  • A database of metrics over time. More power (queries, dashboards) but heavy operational surface for a personal, shareable CLI. Rejected for v1 in favour of append-only JSON files that live next to the grant draft in git.

Consequences

  • Snapshots are internally consistent as-of a single moment. There is deliberately no “refresh one failed Provider into an existing Snapshot” — a re-run produces a new Snapshot rather than a patchwork of fetch times.
  • about is always-live (no cross-run cache that could serve stale numbers); render/diff are always-offline. The two verbs mean “get the truth now” vs “work with truths already captured.”
  • The Snapshot is the compatibility surface: it carries a versioned schema so old Snapshots remain renderable as the tool evolves.

Metric honesty model

Status

accepted — rule 2’s channel-comparability clause is refined by ADR-0009, which lets a weaker-unit channel join a Rollup provided its caveat travels with the total into every format; rule 1’s “missing key” example is refined by ADR-0010, which puts a permanently unreadable channel under NotApplicable rather than Failed

Context and decision

boast exists to make impact claims that survive scrutiny, so the data model is built to make understating impossible to do by accident and overstating impossible to do silently. Three rules govern the model:

  1. Three-state Outcome. Every Provider×Identity fetch resolves to exactly one of Value (a real number), NotApplicable (the Identity legitimately has no presence on that channel — e.g. samtools has no npm package), or Failed (a transient error: rate limit, timeout, 5xx). NotApplicable and Failed are never coerced to 0 — a missing number and a zero number are different facts, and conflating them silently understates a tool’s reach. The dividing line is whether a number is obtainable: a channel no retry and no available configuration could ever yield — a missing key, or an auth wall boast has no credential for — is NotApplicable, not Failed. See ADR-0010, which replaces this clause’s original “missing key” example.

  2. Windows gate summation. Every Metric carries a coverage Windowcumulative (all-time), trailing (rolling N days, e.g. Homebrew’s 365-day installs), or periodic (a named bucket). Metrics may only be combined into a Rollup when their Windows are compatible, and a Rollup must name every Metric it includes. The tool never silently sums an all-time crates.io count with a 365-day Homebrew count. Channels whose units differ in strength (a Conda install ≠ a Docker pull ≠ a git clone) may still be summed, but only where the Rollup names each one and the weaker channel’s caveat travels with the total into every format it appears in — see ADR-0009, which replaces this clause’s original blanket prohibition.

  3. Best-effort with a truthful exit code. One dead Provider never blocks the rest; transient failures get retries with backoff; but the process exits non-zero if any Failed outcomes remain, so a partial Snapshot is distinguishable from a complete one before anyone quotes it. Reports visibly mark partial data.

Considered options

  • Coerce missing/failed to 0 and always exit 0. Simpler code and prettier tables, but it turns the tool into something that quietly lies in the exact direction that damages a grant. Rejected outright.
  • Fail-fast on the first Provider error. One rate-limited API would abort an otherwise-complete run. Rejected in favour of best-effort + explicit per-fetch Outcomes.

Consequences

  • The Snapshot schema must represent NotApplicable/Failed explicitly (with the error), not by omission.
  • Callers (CI, scripts, the user) can gate on exit code to avoid quoting incomplete data.

Data source strategy and deliberate exclusions

Status

accepted

Context

Impact data comes from many services with different coverage, cost, terms, and stability. That set changed materially in late 2025 and early 2026. This ADR records why the default Provider set looks the way it does, including the deliberate exclusions, so nobody re-litigates them in six months.

Decision

Default set (all keyless unless noted), grouped by Category:

  • Code: GitHub (stars, forks, watchers, contributors, total release downloads, repo age, Cohort rank by topic).
  • Downloads: Anaconda.org (any channel — bioconda, conda-forge, or otherwise), PyPI, GitHub release assets, crates.io, Homebrew — reported per-channel, with a labelled Rollup.
  • Citations: OpenAlex (headline count + field-normalized FWCI + citation_normalized_percentile), Crossref (authoritative metadata), Europe PMC (life sciences cross-check), Dimensions badge API (count + recent count from the last two calendar years + FCR + RCR).
  • Attention: open-access status (OpenAlex), Wikipedia mentions, and indexed scholarly repository mentions (OpenAlex + Europe PMC) as keyless baseline defaults; Altmetric as an opt-in richer Provider.

Ranking. A paper’s “standing among similar work” is delivered by field-normalized metrics that already exist for free — OpenAlex percentile/FWCI and Dimensions FCR/RCR — rather than any manually constructed ranking. A repo’s peer comparison is a GitHub topic Cohort (rank by stars among repos carrying a topic), chosen because it needs no manual setup and is reproducible; its dependence on inconsistent topic tagging is disclosed in every Report, not hidden.

Deliberate exclusions (the less obvious part)

  • No Google Scholar. No official API, robots.txt forbids automated access, and it blocks scrapers aggressively; reliable access needs a paid third-party proxy. A built-in scraper would be the one component that silently breaks and can get a user’s IP blocked, which is fatal for a tool that must be reliable and reproducible. Excluded despite it being a metric users personally like.
  • Altmetric is key-gated, not default-free. As of 10 November 2025 Altmetric’s Details-Page API requires an API key for all users; the old free badge endpoint now 403s. Rich attention data requires a key. That is a property of Altmetric’s service, not a limitation of boast. ALTMETRIC_KEY must specifically be a Details Page API key (the /v1/fetch/doi/{doi} lookup for one article that this Provider calls) — Altmetric Explorer, the institutional analytics dashboard product, is a different API with its own key/secret pair that will not authenticate here (confirmed directly against the live API: an Explorer credential gets a clear “API key … not recognized”). A Details Page API key comes from either an institutional licence (ask your library — many universities that pay for Explorer don’t automatically also license the Details Page API for individual researchers) or Altmetric’s SRAD (Scientometric Research Access to Data) program, a free application-based route for non-commercial research. Neither is instant, so don’t expect a same-day key. The exact field-name shape this Provider parses (score, cited_by_msm_count, cited_by_feeds_count, cited_by_policies_count, cited_by_patents_count, cited_by_tweeters_count, readers.mendeley) was cross-checked against public documentation and a real third-party client’s source, but has never been confirmed against a live successful response — nobody involved in building this had Details Page API access. If you get real access, running the Provider once and comparing output against what you see on the paper’s own Altmetric page is the one remaining gap; a response this Provider can’t recognise at all comes back Failed, not a silent zero, specifically so a schema mismatch can’t masquerade as “no attention.”
  • No Crossref Event Data. The main free, keyless attention feed (Wikipedia/news/blog/social mentions of a DOI) was sunset on 23 April 2026; its replacement only exposes dataset-citation relationships. This is why keyless attention is “lite” (OA status + Wikipedia) rather than a full attention donut.
  • GitHub “used by / dependents” is opt-in, not default. Arguably the best reach signal for a library, but there is no API — the count only exists on the scraped /network/dependents HTML page and breaks when GitHub changes markup. Offered as an optional metric with an explicit caveat rather than a default.
  • Issue/PR counts omitted from the default Code set — they read as activity/maintenance, not reach.

Consequences

  • Providers are pluggable behind a common trait so paid/optional sources (Semantic Scholar, Altmetric, GitLab, Docker/Quay, CRAN, Bioconductor, npm) and future replacements slot in without touching the core.
  • Reports must carry source attribution and the topic Cohort disclaimer, because the credibility of a claim depends on which Provider produced it.

No host-native dependencies; rustls, not OpenSSL

Status

accepted

Context and decision

boast is meant to be trivially installable and distributable everywhere (crates.io, Bioconda, Homebrew) and to cross-compile cleanly, including to static *-musl targets. The classic blocker to that is a dependency that links a host C library — above all OpenSSL, which routinely breaks cross-compilation and static linking.

We therefore adopt a dependency policy: no crate that requires a host-installed native library. Concretely:

  • TLS is rustls, never openssl/native-tls. The HTTP stack must be built with rustls (e.g. reqwest with default-features = false + rustls-tls, or a rustls-based client such as ureq). native-tls/openssl/openssl-sys are prohibited anywhere in the tree.
  • Prefer pure-Rust crates and avoid *-sys crates. Parsing, serialisation (serde/JSON/TOML), and everything else should be pure Rust so that a cross-build needs only a Rust toolchain and the target, not a cross C toolchain or vendored system libraries.
  • CI builds and releases static x86_64-unknown-linux-musl (and other) targets to prove the constraint holds and to ship dependency-free binaries.

Considered options

  • reqwest with the default native-tls/OpenSSL backend — the most common Rust HTTP setup, but it drags in OpenSSL and the cross-compilation/static-linking pain we are explicitly avoiding. Rejected.
  • rustls with aws-lc-rs vs ring backend — both avoid host OpenSSL and cross-compile far more cleanly than OpenSSL; either is acceptable. If a build environment makes aws-lc-rs’s C/asm awkward, the ring backend is the fallback. This is an internal knob, not a user-facing decision.

Consequences

  • The concrete HTTP client is contained behind the single HTTP-transport seam (see the v1 spec and ADR-0001/0002), so swapping clients — or TLS backends — is a localised change that does not touch Providers or the Snapshot model.
  • Any future Provider or feature that would pull in a host-native dependency must be reworked or rejected; this constraint outranks convenience.
  • rustls validates against a bundled/vendored root store (e.g. webpki-roots) rather than the host trust store, keeping behaviour identical across platforms — an intended consequence, not an oversight.

Provider licence notices belong in Reports, not logs

Status

accepted — refined by ADR-0008, which scopes this footer to licence/terms text and gives NotApplicable/Failed messages a sibling section of their own

Context and decision

Some Providers’ terms require visible attribution wherever their data is displayed — e.g. the Dimensions Metrics API terms ask for “an attribution [on] the page where the metrics are displayed” (added alongside the Dimensions Provider, #8). A boast Report is rendered from a Snapshot and can be rendered long after the original fetch: render/diff work purely offline on stored Snapshot JSON with no re-fetch (ADR-0001). A notice printed only to stderr during the original about run would not exist by the time someone later runs render on the saved Snapshot, hands the Snapshot file to a teammate, or pastes a rendered Report into a grant draft. In all of those cases, the Report is the page where the metrics are displayed.

So a Provider’s licence/terms notice is recorded on the Metric it accompanies (Metric.note), inside the Snapshot, so it survives serialization and offline re-rendering — and it is shown in the Report, not logged. To avoid repeating the same notice once per Identity (e.g. several DOIs all carrying the same Dimensions boilerplate), the terminal Report treats any note past a length threshold as a Provider-level notice rather than a per-row interpretive gloss, de-duplicates by exact text, and prints each distinct notice once in a footer section — so a run over many DOIs still shows the notice exactly once, not once per DOI.

Considered options

  • Log to stderr instead of the Report. Keeps the table clean and matches how these terms are usually satisfied in practice (a webpage embedding a Provider’s JS badge, not a CLI). Rejected: a Snapshot is meant to be rendered again later without re-running the fetch (ADR-0001), and a log line from the original run wouldn’t be there for that later render or for a teammate handed just the Snapshot file.
  • Print inline on every Metric/row that carries the notice. Simplest, but produces a duplicated, table-breaking wall of text as soon as more than one Identity shares the Provider (a ~250-character notice on every DOI’s citations row). Rejected in favour of a de-duplicated, once-per-run footer.

Consequences

  • Metric.note does double duty for v1 — a short interpretive gloss (e.g. “field-weighted citation impact; 1.0 = world average”) or a Provider’s legal notice — distinguished purely by length in the renderer. A future Provider needing the same treatment reuses this without a Snapshot schema change; if the distinction ever needs to be explicit, promoting it to a typed field is the escape hatch.
  • Reports must carry a duplicate-free but complete set of the notices behind the numbers they display — the Report is the artifact people paste into a grant proposal, so attribution travels with it, not with the terminal session that produced it.

ORCID expands to Identities; it is not one

Status

accepted

Context and decision

An ORCID iD identifies a researcher. Every existing Identity — a paper, a code repository, a distribution package — identifies a piece of work that a Provider can fetch Metrics for. Supporting ORCID therefore forced a choice about which of those two things it is.

We decided: an ORCID is an input expander, not an Identity. boast init --orcid <ORCID> resolves it, once, into the set of Paper Identities the researcher has claimed, and writes them to a Manifest. The Identity enum is unchanged, and no Provider ever receives an ORCID.

Two consequences follow directly, and are deliberate rather than incidental:

  • boast about orcid:… is refused, with an error pointing at boast init --orcid. Identity::parse recognises the ORCID shape (bare, orcid:-prefixed, and URL forms) solely in order to give a better refusal than the generic “could not recognise” catch-all.
  • Expansion produces one Project per work. CONTEXT.md defines a Project as “a single piece of research work”, so a researcher’s 118 papers are 118 Projects, not one Project with 118 papers. This reuses the existing Manifest batch pipeline whole, with no new orchestration.

The expansion reads ORCID’s own public API (pub.orcid.org/v3.0/{orcid}/works), which is keyless and returns the researcher’s self-curated record.

Considered options

  • Make ORCID a new Identity kind (Identity::Researcher), with a Provider emitting Metrics about the researcher — h-index, i10-index, works count, total citations. These are available without a key: OpenAlex’s /authors/{orcid} endpoint returns all of them in one call, and h-index is relevant to the grant-writing use case boast exists to serve. Rejected for now, on product grounds rather than technical ones: it would require rewriting CONTEXT.md’s definitions of both Project (“a single piece of research work”) and Identity (“one external handle a Project links to”), because a researcher is neither. That shifts boast from “how far did this piece of work reach” toward “how accomplished is this person” — a decision that deserves to be made deliberately, not to arrive as a side effect of adding ORCID support. Tracked separately; this ADR would need superseding if it ships.

  • Expand via OpenAlex’s author→works path (/authors/{orcid}works_api_url) instead of ORCID’s own API. It finds work the researcher never claimed, but attribution is algorithmic and produces false positives on common names. That would spend requests measuring other people’s papers and report the total as yours. Rejected: “what I claim as mine” is the better source of truth than “what an algorithm infers is mine”, and a thin ORCID record is best fixed at ORCID, where it benefits the researcher everywhere rather than only here.

  • Let boast about orcid:… run directly, expanding and fetching in one command. Rejected: expansion is cheap (one request) but the run it triggers is not — six Providers support papers, so ~118 works is ~700 requests. Putting a mandatory, reviewable artifact between the two makes the expensive step deliberate, and gives the user somewhere to prune before spending it. This is the same reasoning that already makes a Manifest a generated save-file rather than a hand-authored config.

Consequences

  • init is no longer categorically offline. It gains a network path. This is legal — ADR-0001 constrains only about (always-live) and render/diff (always-offline) — but it is a change in character, so init’s help text must say so rather than let users assume otherwise.

  • Works with neither a DOI nor a PMID cannot become Identities and are skipped. Their count is always written into the generated Manifest’s header, whether or not the user asks to see them listed. Silently shortening the record would understate a researcher’s output — the same failure shape ADR-0002 forbids for Metrics (“we couldn’t look it up” must never read as “it isn’t there”), applied here to a Manifest instead.

  • The generated Manifest must remain valid and runnable as written. Skipped works are therefore emitted as commented-out blocks under --include-unidentified, never as placeholder identities like doi:FIXME — a placeholder would make the freshly generated file fail to parse on the very next command, shipping the user something broken by default.

  • Because expansion is one-per-work, a large record produces a large Manifest and a long run. That cost is surfaced up front, at init time, as a computed warning naming the actual request count — not discovered later when about runs.

Fetches run parallel across hosts, strictly serial within one host

Status

accepted

Context and decision

orchestrator::run used to be a plain nested for identity { for provider { … } } loop — every fetch sequential. That’s fine for one DOI (~6 Providers), but a Manifest expanded from ORCID (see ADR-0006) can hold ~118 works, i.e. ~118 × 6 ≈ 700 sequential requests: a multi-minute floor before RetryingTransport’s backoff makes a bad run worse.

We decided: fetches run concurrently across hosts, but never concurrently against the same host. Crossref and OpenAlex operate “polite pools” that expect a sane request rate from a single client; firing many concurrent requests at one host would get us throttled or blocked — worse than the sequential status quo. Because every current Provider maps 1:1 onto a distinct host, parallelising across Providers yields roughly 6× speedup while staying politer than a naive “parallelise everything” approach: each host still sees requests one at a time, just from several hosts at once.

Concretely, orchestrator::run builds the full Provider×Identity job list up front (same order as the old nested loop), groups jobs by Provider name (standing in for host), and runs a bounded pool of std::thread::scope worker threads that each pull one host’s job queue at a time and drain it strictly in order before picking up another. ureq is blocking, so threads are the natural fit; tokio is unnecessary and would cut against ADR-0004’s minimal-dependency posture. No new dependencies were added. This requires Provider and Transport to be Sync, since fetches now happen through a shared reference from multiple threads.

Considered options

  • Parallelise across everything (identities × providers), bounded only by a global limit. Simpler, and would yield a bigger speedup on a Project with many Identities against the same Provider. Rejected: it reintroduces the exact problem this ADR exists to avoid — concurrent requests to Crossref/OpenAlex’s polite pools — for a speedup that isn’t the bottleneck (there are ~11 hosts but rarely more than a handful of same-host Identities in one Project).
  • tokio + async Providers. Would parallelise more cheaply than OS threads at very high fan-out. Rejected: fan-out here is bounded by the number of distinct hosts (~11), not thousands of connections, so async’s main advantage doesn’t apply; it would add a large dependency tree purely for a workload threads already handle well, cutting against ADR-0004.
  • Unbounded thread-per-job. Simplest possible parallel version. Rejected outright: a 700-job Manifest run would spawn 700 OS threads, and nothing bounds that as Manifests grow.

Consequences

  • Snapshot result ordering had to become explicitly deterministic, not just incidentally so. Snapshots are committed artifacts (see the CI-snapshot ticket): if results were serialised in completion order, every run would reorder its JSON and produce spurious diffs, breaking the reproducibility premise ADR-0001 relies on. run now pre-assigns each job a slot by its position in the original (identity-major, provider-minor) job list and writes into that slot regardless of which thread finishes when, so the final results order is always the job order — proven by a test that deliberately reverses completion order and asserts the output order is unaffected.
  • Provider and Transport gained a Sync supertrait bound. Every real implementation satisfied it automatically (plain data, no interior mutability) except MockTransport’s scripted-sequence replies, which moved from RefCell to Mutex — an internal change only; its public test API is unchanged.
  • A Provider’s name is used as its host key, rather than parsing the actual request URL’s host. This is accurate today (every Provider maps 1:1 onto one host) but is an assumption, not an invariant enforced by the type system — a future Provider that fans out across multiple real hosts, or two Providers sharing one host, would silently violate the “never concurrent within a host” guarantee this ADR relies on.
  • Concurrency is bounded by a default of 8 (orchestrator::DEFAULT_CONCURRENCY), user-tunable down via about -j/--threads, independent of how many hosts or Identities are involved, so worker-thread count never scales with Manifest size. Raising it past the number of hosts a Project actually touches (at most the Provider registry’s size, ~11 by default) buys nothing — there’s no further axis to parallelise on, since a host’s own queue is always drained strictly serially regardless of the cap. The meaningful direction to tune is down (to 1, fully sequential), e.g. to open fewer simultaneous connections on a constrained network; 0 is rejected by the CLI and treated as 1 by the library function, since it would otherwise leave every job queued with no worker to run it.

Operational Provider notes are a sibling footer section, not licence notices

Status

accepted — the prose carve-out in Considered options is refined by ADR-0009: what travels into prose is any Metric.note attached to a headline number, not only a licence notice. Extended during #83 so a successful Values Outcome may also carry Provider Notes when requested secondary Metrics were only partly collected; operational messages still stay out of prose.

Context and decision

report.rs gates every row’s detail text on INLINE_DETAIL_LIMIT (80 characters). Only Metric.note had a fallback past that gate: promotion to the once-per-run ── Notices ── footer (ADR-0005). A NotApplicable/Failed Outcome’s message went through the identical gate with no fallback at all, because provider_notices scans snapshot.metrics() and neither of those Outcomes carries any Metrics. Any such message over the limit was therefore dropped silently from both the terminal and Markdown Reports — the row rendered as a bare N/A/FAILED with nothing saying why. This was found by manual end-to-end testing while implementing the Attention Category, not by a test, and was worked around at the time by shortening the offending Altmetric message to fit under the limit: a fix that left the trap armed for the next Provider to write a longer one.

The obvious repair is to route those messages into the existing footer. We decided against it: a licence notice and an operational explanation are different kinds of text and need different rules. ADR-0005’s footer exists to satisfy Providers whose terms require visible attribution wherever their numbers are displayed, and it de-duplicates by exact text precisely because the same Dimensions boilerplate legitimately repeats across every DOI. Attribution is a property of the data, so discarding Provider×Identity from it is correct. An operational message — “no API key configured”, “rate limited after three retries” — is the opposite kind of thing: it is about one Provider’s attempt on one Identity, and discarding that context is what makes it useless.

The same distinction applies when a Provider returns valid core Metrics but cannot collect every requested secondary Metric. Treating the whole Outcome as Failed would discard real data; putting the explanation on a Metric would misdescribe that number. A Values Outcome may therefore carry Provider Notes describing the partial collection. These notes survive in the Snapshot and Report, but do not by themselves make the default command exit non-zero.

An operational message over the inline limit is promoted to a second footer section, ── Provider Notes ── (## Provider Notes in Markdown), rendered after ── Notices ──. Entries de-duplicate on (provider, outcome kind, message). Identity drops out of the key, so one shared message collapses to a single line naming the Identities it covers; Provider and Outcome kind stay in it, so altmetric’s N/A can never merge with crates.io’s, and a NotApplicable can never merge with a Failed — ADR-0002’s honesty model turns on those two staying distinguishable. The row itself is left untouched, with no “see below” pointer, since the footer names its own Identities and the table stays readable without one.

Considered options

  • Extend provider_notices to scan NotApplicable/Failed as well. One code path, smallest possible diff, and it does close the drop. Rejected: it mixes operational noise into the one block that exists to be pasted into a grant proposal alongside the numbers, and it inherits dedup-by-exact-text — so a dozen Identities failing would collapse to a single line with no way to tell which, and two Providers emitting the same generic error would silently merge.
  • Wrap the long text inline beneath its own row instead of promoting it. No footer, no dedup question at all, and Provider×Identity adjacency comes for free. Rejected: a missing API key produces one identical message per Identity, so a run expanded from ORCID (ADR-0006, ~118 works) would print the same sentence ~118 times — the exact wall of text ADR-0005 rejected when it chose a footer over inline notices in the first place.
  • Mark the affected row with a pointer to the footer, or with a truncated inline preview. Rejected: the pointer adds a constant string to every affected row carrying information the footer already provides, and the preview duplicates the text in two places while reintroducing mid-word truncation — a bug class this codebase has already been bitten by.
  • Surface these in render_prose and diff::render too. Rejected for now, on separate grounds. Prose is the automatically written sentence for grant writing and deliberately carries only headline numbers plus the licence notice attached to them, so operational caveats do not belong in it. diff::render never rendered NotApplicable/Failed messages at any length, so extending it is a feature gap rather than part of this drop, and is tracked separately.
  • Print partial-success warnings only to stderr. Rejected: Snapshots are durable and render offline, so the explanation would disappear when the Snapshot was later rendered or shared.
  • Turn a partial Values Outcome into Failed. Rejected: valid core Metrics would be discarded, and #83 deliberately keeps best-effort Cohort ranking as a successful default command.

Consequences

  • Reports have two footer sections with deliberately different semantics: a Metric.note over the limit goes to Notices; an operational note carried by any Outcome goes to Provider Notes. A Provider author choosing where their text lands chooses whether it qualifies a Metric or explains collection status, not a section.
  • Length still decides inline-versus-footer within each kind, so INLINE_DETAIL_LIMIT keeps the double duty ADR-0005 gave it. A short operational message stays on its row and never reaches the footer, which means a short message shared across many Identities still repeats once per row. Accepted: it is short by definition, and the alternative is promoting text that fits perfectly well where it already is.
  • The workaround comment on the Altmetric Provider — keep this message under 80 characters — becomes obsolete and should be removed with the fix. It is the only place the trap was written down, so leaving it would keep telling future Provider authors to write around a limit that no longer bites.
  • Provider Note enters the glossary as a term distinct from Notice, so the two are nameable in review instead of both being “the long-text thing”.
  • The original NotApplicable/Failed rendering fix did not change the Snapshot. Partial Values notes do: the Outcome gains a backward-compatible optional collection with an empty default, and the Snapshot schema version advances. Existing Snapshots without the field remain readable.
  • diff::render still shows neither kind of message, so a diff can present an inconclusive entry without the Failed text that would explain it. Tracked as follow-up work rather than fixed here.

Container pulls roll up, with the caveat travelling

Status

accepted — refines ADR-0002 rule 2, which named a Docker pull as a channel never to be summed

Context and decision

ADR-0002 rule 2 ends: “never sums across incomparable channels (a Conda download ≠ a Docker pull ≠ a git clone)”. Written before any container Provider existed, it settled the question by example rather than by rule, and the example it reached for is the one the Docker Hub Provider (#71) now needs to answer.

The example is right about the facts. A Docker Hub pull is not a conda install: it counts manifest fetches, so CI re-runs, layer probes, and mirror warming all land in the same figure, and the counter never resets — library/ubuntu sits near ten billion. Summing it with a conda count produces a total whose magnitude is driven by machine traffic.

But the clause proves more than it should. By that reasoning a crates.io download is not a PyPI download either — different ecosystems, different retry behaviour, different mirror topologies — yet those have been summed since v1 without objection. Every channel is incomparable to every other at some resolution. What ADR-0002 actually protects is the reader’s ability to see what a total is made of, and the mechanism it built for that is the Rollup’s own construction: a Rollup must name every Metric it includes. A total that names docker:biocontainers/samtools (596335) next to conda:bioconda/samtools (9054107) has not hidden anything.

So the rule is restated in terms of what is actually enforceable:

A Metric may join a Rollup when its Window is compatible and the Rollup names it. A channel whose unit is weaker than the others’ must carry a note explaining how, and that note travels with the total into every format the total appears in — including prose.

Windows still gate summation exactly as before; that part of rule 2 is untouched. What changes is that channel comparability is handled by disclosure rather than by exclusion.

The second half is not decoration, and it is where the original implementation of #71 was wrong. render_prose appended only the headline citation notice, so the sentence read:

As of 2026-08-05, this project has been downloaded 9650478 times (all-time) across 2 channels.

596,335 machine pulls, folded into a grant sentence, with the caveat sitting in a terminal footer the reader of that sentence never sees. Prose is the format that shows a total without the per-channel breakdown, which makes it the one format where the caveat is load-bearing rather than supplementary — and it was the only one where it was missing. Any notice attached to a Metric behind a headline number now travels with it.

Considered options

  • Keep ADR-0002 as written and exclude container pulls from the Rollup. Honours the existing text with a three-line change to counts_as_download, and makes the prose gap moot. Rejected: it treats “incomparable” as a property a channel either has or lacks, which does not survive contact with crates.io-versus-PyPI, and it leaves the reader with two numbers to add up themselves — which they will, without the caveat.
  • Include, and rely on the Notices footer alone. The terminal and Markdown Reports do show it. Rejected: this was the original #71 implementation, and it is precisely the hole above. A rule that holds in two of three formats is not a rule.
  • Make the Provider non-default / opt-in. ADR-0003 lists Docker/Quay among pluggable optional sources, so this has real support in the existing record. Rejected for now: no non-default Provider registry exists yet (providers/mod.rs notes the DEFAULT column reads “yes” throughout for want of a contrast), and building one to dodge a disclosure question is the wrong order. Revisit if an opt-in tier is built for other reasons.
  • Give the Rollup a notion of rollup-ineligibility separate from Window. Most expressive: a Metric could be shown under Downloads yet marked as never summable. Rejected as speculative with one caller — and it would have re-answered the comparability question as exclusion anyway.

Consequences

  • ADR-0002 rule 2’s parenthetical no longer holds as written and is amended in place to point here, rather than being left to contradict shipped behaviour. Its substantive requirement — Windows gate summation, a Rollup names its members — is unchanged and is what this ADR leans on.

  • render_prose now appends notices from the headline downloads Metrics as well as the headline citation Metric. This is the ADR-0005 rule applied consistently rather than a new one: a notice attached to a quoted number follows it into every format.

  • ADR-0008’s “operational caveats do not belong in prose” still stands, but its wording narrows too far and is amended to point here. The split is by Outcome kind, not by content: a Metric.note on a real Value is a Notice and travels; a NotApplicable/Failed message is a Provider Note and does not. ADR-0008 described the travelling kind as “the licence notice”, because Dimensions’ licence text was the only instance then in existence. A caveat qualifying a number that was collected is the same kind and travels for the same reason.

  • This changes prose for one existing Provider, deliberately. PyPI attaches a note explaining that pypistats’ “last month” bucket is treated as a trailing 30 days because no exact day boundary is published. That note now follows the figure into prose, where the sentence says “last 30 days” without qualification. It is the same class of defect this ADR was written to fix, found by the same reasoning, so it is fixed rather than grandfathered.

  • A Provider adding a weak-unit channel now owes a note, not just a Metric. docker_hub.rs is the worked example. There is no mechanism forcing this, so it is a review obligation — the same status as the Category a Provider picks.

  • long_notes becomes the single definition of “a note long enough to be a notice”, shared by the whole-Snapshot footer and the new headline-scoped prose path, so the two can never disagree about what qualifies.

  • Nothing about the Snapshot changes. Notes were always recorded on their Metrics and always survived to the JSON; this was a rendering gap. No schema bump, and existing Snapshots re-render under the new rules.

An unreadable channel is NotApplicable, not Failed

Status

accepted — refines ADR-0002 rule 1, whose Failed examples include “missing key”

Context and decision

ADR-0002 rule 1 defines the three-state Outcome, and lists what belongs in each: NotApplicable is “the Identity legitimately has no presence on that channel”, Failed is “a transient error: rate limit, timeout, 5xx, missing key”.

The Quay.io Provider (#72) forced the question that list papers over. Quay answers 401, never 404, for any repository an unauthenticated caller cannot see — verified live against a missing repository, a missing namespace, and by construction a private one. The three are deliberately indistinguishable, so the registry cannot be enumerated. There is no 404 to key off.

Read literally, ADR-0002 sends that 401 to Failed: it is an auth response, and for a genuinely private repository “the number exists but wasn’t retrievable” is precisely true. But Failed makes two claims that are both wrong here, and rule 3 turns the second into user-visible damage:

  1. That retrying might work. It cannot. RetryingTransport doesn’t retry 401 (correctly — it isn’t transient), and boast holds no Quay credential to retry with. The Provider is keyless by design; there is no configuration in which this 401 becomes a number.
  2. That the Snapshot is partial. Rule 3 exits non-zero when any Failed remains, so boast about --package quay:biocontainers/typo would report a broken run — and, worse, a Manifest naming one package not published on Quay would permanently fail every scheduled run that includes it. A package’s absence from a registry is not a defect in the fetch.

So the rule is restated in terms of what the caller can act on:

Failed means the number is retrievable and this attempt didn’t get it. Where no retry and no available configuration could ever yield a number, the Outcome is NotApplicable, whatever status code carried that news — with any ambiguity disclosed in the note.

This is not new behaviour so much as the existing practice written down. Altmetric already classes a missing ALTMETRIC_KEY as NotApplicable (altmetric.rs’s NO_KEY_NOTE), and CONTEXT.md’s Provider Note glossary entry already gives “no API key configured” as a NotApplicable example — directly contradicting ADR-0002 rule 1’s own parenthetical. The contradiction has been latent since v1; Quay is the first case where it changes an Outcome.

The disclosure half is load-bearing. Quay’s 401 genuinely is ambiguous, so the note says so rather than asserting an absence it cannot verify: “no public repository on Quay.io (Quay answers alike for a missing or a private repository, so this may be a private image)”. NotApplicable here claims only that there is no public presence — which is exactly what was observed.

Considered options

  • Send the 401 to Failed, per rule 1’s literal text. Honours the ADR as written and needs no code. Rejected: it marks every run touching a package that simply isn’t on Quay as partial, and invites the user to retry something that can never succeed. It would make the exit code — the thing rule 3 exists to keep meaningful — fire on a non-event.
  • Distinguish missing from private before classifying. Most honest in principle. Rejected: impossible by construction. Quay’s anti-enumeration behaviour exists specifically to deny this distinction to unauthenticated callers, and boast is an unauthenticated caller by design.
  • Add a fourth Outcome state (e.g. Unreadable). Expressive, and would keep rule 1’s wording intact. Rejected as a large blast radius — Outcome is serialized into every Snapshot (a schema bump), matched in the orchestrator, both renderers, and diff — for one Provider’s status code, and it would still have to pick an exit-code behaviour, which is the actual question. NotApplicable plus an honest note already answers it.
  • Give the Provider an optional QUAY_TOKEN so private repos resolve. Would make the 401 a real “missing key” and put it back under rule 1 legitimately. Rejected as out of scope and low value: boast measures public reach, and a private image has none to measure by definition.

Consequences

  • ADR-0002 rule 1’s “missing key” example no longer holds as written and is amended in place to point here. The three-state model and the never-coerce-to-0 rule are untouched — this is about which of two existing states a case lands in, not about inventing leniency.
  • A Provider may now classify a non-404 status as NotApplicable, where the status means “you will never see this”. quay.rs is the worked example; provider::classify_status remains the default for everything else, and still sends unrecognised statuses to Failed. Nothing is relaxed by default: a Provider has to opt in, deliberately, per status.
  • The exit code keeps meaning what rule 3 says it means. A partial Snapshot is still distinguishable from a complete one — this change removes a class of false positive from that signal rather than weakening it.
  • The risk is a genuine outage misread as absence. If Quay were to start 401-ing broadly — auth-gating the API, or rate-limiting via 401 rather than 429 — every lookup would report N/A with a zero exit code, and a reader would see “not published on Quay” when the truth is “Quay is closed”. This is the real cost of the decision, accepted because the alternative misclassifies the common case to protect against a hypothetical one, and because the note keeps the reason visible in the Report either way. Worth revisiting if Quay’s behaviour changes.
  • NotApplicable notes now carry disclosure obligations, not just a reason. Where a Provider cannot distinguish absence from invisibility, the note says so. A note that flatly asserted “not found on Quay.io” would be stating something the response never established.