The most useful contribution is a provider. Coverage is the thing that takes calendar time, and it is the part that cannot be rushed by writing code faster.
If a provider publishes a versioned OpenAPI spec in a public git repository,
the whole integration is four lines in providers.yaml:
stripe:
repo: stripe/openapi
path: openapi/spec3.json
markers: [stripe, STRIPE_SECRET, STRIPE_API]| Field | What it is |
|---|---|
repo |
the public git repository holding the spec |
path |
path to the spec inside it. .json or .yaml; the other extension is tried automatically, because providers rename these over the years |
markers |
strings that must appear somewhere in a file before non-model artifacts from this provider count as a finding there |
They are what stops a retired parameter named refund from flagging every
codebase that has ever mentioned a refund. They are the difference between a
tool people leave switched on and one they mute in a week.
Pick strings that only appear in files that genuinely talk to that provider: the SDK import name, the environment variable holding the key, the API hostname.
python3 scanner/openapi_diff.py --provider <name> --from <old-ref> --to <new-ref>Then read the artifacts it produced and ask whether each one would be a true
positive in somebody else's repository. Enum values are where this goes wrong:
GitHub's spec once retired values called None, Remove and number, and
emitting those would have flagged a quoted string in every file that mentions
GitHub. The generator filters them now, but new providers find new ways to be
generic.
Model retirements are almost always published as prose in documentation and
never appear in a spec, so those are curated by hand in
manifests/<name>.yaml. Follow spec/MANIFEST.md.
Every artifact must carry an evidence URL pointing at the provider's own
page. A fact without a source does not go in a manifest. That rule is the
entire trust model, and a pull request that skips it will not be merged.
Model IDs normally skip the file_markers gate, because a string like
claude-3-opus-20240229 cannot plausibly mean anything else. Resellers break
that assumption. Azure serves gpt-4o and claude-sonnet-4-5 under Azure's
own, earlier retirement dates, so shipping those as ordinary model IDs told
somebody calling OpenAI directly that their model was breaking, citing
Microsoft. Set require_context: true on any artifact whose literal another
provider also serves, and it is then reported only in files that match this
manifest's markers.
Bedrock is the counter-example worth reading before you reach for the flag. Its
IDs are anthropic.claude-3-5-sonnet-20240620-v1:0, which name the reseller in
the literal, so they need no gate. The flag is for a collision, not for a
reseller.
azure-models.yaml is currently held out of feed/feed.json by a block in
scanner/build_feed.py, and the build prints why every time it runs: npm
serves driftcite 0.2.0, which predates require_context and ignores it, and
that published client fetches this feed at runtime. Shipping those 42
artifacts would tell every npx driftcite user that claude-sonnet-4-5 was
breaking, citing Microsoft, in code that never touched Azure. The manifest
keeps the artifacts; only the published feed withholds them, until a release
that can read the field is the one people run.
A scheduled workflow runs four observers every morning, rebuilds the feed, and commits what they saw. Three of the four exist because a curated manifest is a transcription, and a transcription rots the day the page behind it is edited.
scanner/refresh.py re-diffs each spec provider against the last ref it
recorded in manifests/state.json.
scanner/watch_pages.py takes no configuration at all. It reads the
sources: list out of every hand-curated manifest — anything under
manifests/ that is not *.generated.yaml, since a generated manifest cites
git compares, which are immutable and pointless to watch — fetches each URL,
strips the markup, and hashes the readable text. A changed hash is not a
finding, it is a work order, and the output names the manifest files that page
feeds so nobody has to remember which page belongs to what. The consequence for
you: adding a URL to sources: is how you put a page under watch. There is no
second list to update.
scanner/probe_models.py asks each provider for its live model list and
records what appeared and vanished, configured under model_lists: in
providers.yaml:
model_lists:
google:
url: https://generativelanguage.googleapis.com/v1beta/models
auth: query-key # bearer | x-api-key | query-key
key_env: [GEMINI_API_KEY, GOOGLE_API_KEY] # tried in order
id_path: models.name # where the IDs live in the response
strip: models/ # optional; Google returns models/gemini-1.5-proA manifest says a model retired because a page said so. The probe says it
because the request went out today and the ID was gone, and nobody can
reconstruct next year which IDs a provider was serving today unless somebody
asked today. It also audits both directions: a manifest claiming retired for
an ID still being served is a contradiction, and an ID that vanished with no
manifest entry is a curation job. A provider with no key in the environment is
skipped by name, never silently.
scanner/probe_sunset.py asks the endpoints themselves, under
sunset_probes::
sunset_probes:
github:
base: https://api.github.com
watch_paths:
- /rate_limitRFC 8594 defines a Sunset response header and RFC 9745 a Deprecation
header, so a provider can announce a retirement in band months before writing
a blog post. Paths come from the endpoint artifacts already in that provider's
manifests, which turns a removal we cite into a removal we witnessed, plus any
watch_paths you list for endpoints that are still alive. Only HEAD is ever
sent and templated paths are skipped: a probe that invents an {owner} is
measuring something other than the API. No key is needed, because a 401 still
carries the response headers.
npm testThat is both suites, 179 tests: test/run.mjs covers the scanner and the CLI,
test/watch.mjs covers the hosted watch in ee/watch/. The 24 under match precision and finding context are the ones to read first, because none of
them was invented. Each is a false positive this scanner produced against a
live codebase and now refuses: refund matching inside "refunded", hosted as
an ordinary English word, a parameter quoted inside a comment. If you change
matching behaviour, add the case that made you change it.
There is no Python test file. The convention instead is a self_test() living
in the script it tests, reachable as --self-test, running in-process against
fixtures with no network and no keys. Four scripts carry one:
python3 scanner/openapi_diff.py --self-test # the artifact filters
python3 scanner/probe_models.py --self-test # appeared, vanished, returned
python3 scanner/probe_sunset.py --self-test # header and status transitions
python3 scanner/build_feed.py --self-test # the feed already committedThey are built in rather than filed away because these are the scripts you
reach for at 2am when a provider has moved, and a check you can run in the same
breath as the command is a check that gets run. If you add a filter or a state
transition, extend the self_test() next to it.
build_feed.py --self-test is the odd one: after a fixture check it opens
feed/feed.json itself and audits the artifacts actually committed, so it
fails on data rather than on code. It exists because nothing else ever opened
the one file every npx driftcite run downloads, and an artifact with no
evidence URL, no literal, or an unparseable retires_on ships perfectly and
fails at the user.
Two gaps worth knowing about rather than discovering. refresh.py, scan.py,
sweep.py and watch_pages.py carry no --self-test at all. And CI runs the
first three of the four above but not build_feed.py --self-test; it rebuilds
the feed and fails on a diff instead, which catches a stale feed and not a bad
artifact inside a fresh one. Run it yourself before you push manifest changes.
python3 scanner/build_feed.pyCI fails if feed/feed.json is out of sync with the manifests, so run this
before pushing when you have touched anything under manifests/.
Code you contribute is under Apache 2.0, like the rest of the scanner.
Manifest data you contribute is dedicated to the public domain under
CC0. By opening a pull
request that adds or edits anything under manifests/, you place that content
in the public domain.
This is deliberate and it protects you more than it protects us. The single most durable grievance in open source relicensing fights has been companies changing the terms on work other people contributed for free. Taking manifest data as CC0 means we can never do that to you: your contribution is already public domain and stays that way no matter what happens to this project.
GitHub's own Advisory Database uses the same arrangement for the same reason.
- No inference in the detection path. A manifest asserts the fact and carries the evidence; the scanner only locates it. The moment driftcite reports something a provider did not actually publish, it is worthless.
- Precision over coverage. A finding that is wrong costs more than a finding that is missing. The feed has gone from 29 artifacts to 235 without one match shape being loosened to accommodate a new provider, and that property is worth more than the next ten providers.