Skip to content

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:

  1. Declare a named fact on the registry (Policy tab or API): a name, a type (number, bool, or string), and an optional description shown on the crate page.
  2. 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.
  3. 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.

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.

PUT /api/v1/registries/{registry}/crates/{name}/{version}/facts
Authorization: <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_recorded event; 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.

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_field on a missing field and file_sha256 on 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-static evidence 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.

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.

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.

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 10
custom 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.