Custom facts & rules
The built-in rules cover what Haven can observe itself. Custom facts
extend the policy engine to what only your pipeline can measure:
test coverage, unsafe counts, fuzzing hours, review sign-offs,
internal scanner verdicts. The loop has three parts:
- Declare a named fact on the registry (Policy tab or API): a
name, a type (
number,bool, orstring), and an optional description shown on the crate page. - Record one value per crate version — through the API, from CI or by hand, with any token of the owning org; or bind an extractor and Haven computes the value from the tarball itself, no pipeline required.
- Rule over the recorded values. A failing rule holds the version
under the
custom:<fact>kind, like any other policy rule.
A fact is a value recorded at a time, never a live query: evaluation runs on every index read and must stay deterministic.
On mirrors, the shape is narrower but rules do apply. Fact
declarations stay a publish-registry feature: a mirror has no push
path, so a declared fact could never be given a value. Extractors are
how a mirrored fact acquires one, and their op fixes its type in place
of a declaration — so a mirror rule may name either an extractor-bound
fact or a haven: built-in, and nothing else. Rules there always hold
and never reject: there is no publish to refuse.
Two consequences worth knowing before you turn one on. A version the analyzer has not reached yet holds as awaiting analysis — a statement about Haven, not about the crate — and clears itself when the value lands. And values recorded under a superseded extractor definition are treated the same way rather than being used, because they answer the question you used to be asking. Mirrors of an upstream with no database dump have no analyzer pass at all, so fact rules are refused there outright instead of silently holding everything.
Static file registries support custom facts too, with two narrowings: there are no extractors (nothing analyzes a files artifact) and rules always hold, never reject. In exchange, values can be pushed on drafts as well as published versions — publishing freezes a version’s files and license, never its annotations. See When the version is held.
Declaring facts
Section titled “Declaring facts”Fact names are 1-64 characters of lowercase letters, digits, _ and
-, starting with a letter or digit. A registry can declare up to 32
facts. Declarations live in the policy document, so the audit log’s
policy_set events record their full history.
Recording facts
Section titled “Recording facts”PUT /api/v1/registries/{registry}/crates/{name}/{version}/factsAuthorization: <token>
{ "unsafe_count": 3, "audited": true }The body is a JSON object of declared names to scalar values. Rules:
- The caller must be an identity of the owning org (a trusted publishing token works, so CI needs no long-lived secret), or a platform service credential.
- Every name must be declared, and every value must match its declared type. Undeclared names are rejected, so a typo in CI fails loudly instead of silently satisfying nothing.
- Last write wins: the registry keeps one current value per version
and metric. Every change lands in the audit log as a
fact_recordedevent; re-sending the same value records nothing. haven:names cannot be pushed; Haven fills those itself.
The response lists which values changed and which were unchanged:
{ "ok": true, "recorded": ["unsafe_count"], "unchanged": ["audited"] }Recorded values appear on the crate page’s Security tab, with who
recorded them and when. On a static file registry the route is the
files sibling,
PUT /api/v1/registries/{registry}/files/{name}/{version}/facts,
with the same body and the same rules.
Extractors
Section titled “Extractors”An extractor computes a declared fact’s value from each version’s tarball, on Haven’s own analyzer — the alternative to recording values from your CI. One extractor per fact, defined in the policy beside the declaration (Policy tab or API):
| Op | Operand | Produces |
|---|---|---|
path_exists |
glob |
bool: some file path matches |
path_count |
glob |
number: how many paths match |
total_bytes |
— | number: sum of all file sizes |
largest_entry |
— | number: the largest single file |
manifest_field |
field (dotted, e.g. package.edition) |
the scalar at that Cargo.toml path |
count_matches |
pattern (+ optional glob filter) |
number: regex matches across file contents |
any_match |
pattern (+ optional glob filter) |
bool: the regex matches anywhere |
file_sha256 |
path |
string: hex sha256 of that file’s contents |
Globs and paths match against the tarball’s contents with its root
directory stripped: write vendor/openssl-sys/**, not
some-crate-1.0.0/vendor/…. Patterns are Rust regex syntax, matched
over raw bytes. The extractor’s output type must match the fact’s
declared type (manifest_field takes whatever scalar the manifest
holds).
Rules to know:
- One fact, one producer. A fact bound to an extractor cannot be pushed through the API; remove the extractor to record values yourself again.
- Editing a definition re-computes its values. The save reports how many versions are affected and asks for confirmation; analysis then catches up in the background, newest versions first. Until a version is analyzed under the current definitions, rules over its extractor-bound facts hold it (“no value recorded”) — the same fail-closed principle as a missing pushed value.
- A failing extractor records no value. A pattern that cannot be evaluated on some tarball (a file too large to scan, a scan budget exceeded) records an error, shown on the crate page’s Security tab, and rules over that fact fail closed for that version.
- Absence is a real answer.
manifest_fieldon a missing field andfile_sha256on a missing file record nothing — the version counts as analyzed, and a rule over the fact fails closed, exactly as if your CI had never pushed it. - Values computed this way carry
tarball-staticevidence and an org-defined origin badge wherever they are shown: they are only as meaningful as the definition you wrote.
Bounds: up to 32 extractors per registry, patterns and globs up to 256 characters. Per analyzed version, content scanning is capped (64 MiB scanned, 8 MiB per file, 10,000 counted matches) — an extractor that exceeds a cap records an error, never a truncated value.
Writing rules
Section titled “Writing rules”Each rule names a fact and a requirement. The comparators:
| Comparator | Applies to | Passes when |
|---|---|---|
is_true / is_false |
bool | the value is exactly that |
under |
number | value < the threshold |
over |
number | value > the threshold |
equals |
any | value equals the operand |
in_set |
any | value is one of up to 32 operands |
has_not_changed |
any | value equals the previous version’s |
has_not_increased |
number | value ≤ the previous version’s |
has_not_decreased |
number | value ≥ the previous version’s |
Rules compose with AND, like everything else in the policy. Up to 64 rules per registry; several rules may target the same fact.
Missing values fail closed. A version with no recorded value for a
rule’s metric is held, on the same principle as require_clean_vcs:
absence is publisher-controlled, so it cannot count as passing. This
includes versions published before the rule existed. Use the Policy
tab’s dry run before saving: it evaluates the candidate policy
against every current version and reports what would be held, without
changing anything.
Cross-version comparators use the nearest earlier valued version
as their baseline. If no earlier version ever recorded the value, the
rule passes: the first valued version becomes the baseline, so
adopting a fact mid-history never holds the back catalog. A typical
pattern: record unsafe_count from
cargo-geiger in CI and
set has_not_increased, so creeping unsafe needs an owner’s
sign-off (a promotion waiver) to ship.
Built-in facts
Section titled “Built-in facts”Rules can also target facts Haven records itself, with no declaration and no recording step:
| Name | Type | Notes |
|---|---|---|
haven:license |
string | declared SPDX expression |
haven:rust_version |
string | declared MSRV |
haven:tarball_bytes |
number | size of the .crate as received |
haven:vcs_sha |
string | commit the tarball claims; absent when none was claimed |
haven:vcs_dirty |
bool | absent when no provenance was claimed |
haven:repository |
string | the repository URL this version declared; on mirrors, read from the packaged manifest by the analyzer |
haven:description |
string | the description this version declared; same sourcing as haven:repository |
haven:homepage |
string | the homepage URL this version declared; same sourcing |
haven:documentation |
string | the docs URL this version declared; same sourcing |
haven:links |
string | the links key this version declared; same sourcing |
haven:edition |
string | the edition the packaged manifest declares; read from the tarball on both registry kinds |
haven:has_build_script |
bool | a build script runs on every consumer’s machine at build time; absent until the tarball pass ran |
haven:is_proc_macro |
bool | the crate is a proc-macro (compile-time code execution); same sourcing |
haven:tarball_parses |
bool | whether the tarball could be opened at all; false is a recorded verdict, and the counters below are then absent |
haven:binary_entries |
number | files that look precompiled (ELF/PE/Mach-O magic, .so/.dll/.a/… extensions) |
haven:hostile_paths |
number | entries with absolute or ..-bearing paths |
haven:symlink_entries |
number | symlink and hardlink entries |
haven:duplicate_entries |
number | entries whose exact path repeats |
haven:high_entropy_entries |
number | files of 64 KiB or more whose sampled head reads as compressed or encrypted rather than source |
haven:decompression_ratio |
number | uncompressed file bytes per compressed byte, to two decimals |
haven:provenance |
string | verification verdict: verified, unclaimed, unverifiable, refuted |
haven:provenance_tier |
number | how far verification got: 2 or 3 |
haven:sbom_present |
bool | always present |
haven:attestations_count |
number | always present; counts every distinct attester, ignoring trusted_attesters |
The two provenance facts come from the
provenance rule’s check and are
absent until it has run; a rule over one holds as awaiting analysis
meanwhile. For the usual case — “require verified provenance” — reach
for that rule rather than a custom rule over these.
The facts known at publish time (all but the provenance, SBOM and
attestation entries) support rules that also reject at publish
instead of holding (per-rule toggle). Rules over
recorded values never reject: the value arrives after the publish is
accepted, when there is no gate left to refuse it. Example: a rule
haven:tarball_bytes has_not_increased with reject on refuses a
release whose tarball suddenly grew, keeping the version number free.
Holds, waivers, and the audit trail
Section titled “Holds, waivers, and the audit trail”A failing custom rule holds the version under the custom:<fact>
kind, visible on the Held tab and quoted in cargo’s publish warnings,
always naming the rule and the actual value:
custom rule `unsafe_count`: value 14 is not under 10custom rule `unsafe_count`: no value recorded (rule requires under 10)Holds clear the usual ways: record a passing value (the hold lifts on the next read), or waive the kind with promotion approvals at the registry’s threshold. All rules sharing the fact share the kind, so one waiver covers them for that version. Custom holds are never permanent.
Every changed value is a fact_recorded audit event carrying the
value and the recording identity, and every policy change snapshots
the declarations and rules, so “who set the value that released this
hold, under which rules” is always answerable from the
audit log.