Skip to main content
Prompts evolve, eval suites go stale, and nobody notices until a regression sails through. multivon-eval staleness is the detection layer: a committed baseline snapshot of every prompt call site in your repo, a read-only report that tells you exactly which prompts changed since your cases were authored, and an opt-in provenance layer binding cases to the prompts they exercise. Shipped in 0.10.0. Built on the attribution scanner: attribution is the scanner, staleness the drift report on top of it.
The design rule that survived every round of the pre-implementation adversarial review: the tool never overclaims what static analysis can know. Every report opens with a determinacy headline and closes with a standing blind-spots footer, both enforced in the output code.

The five drift modes (and which this covers)

Eval suites rot in five distinct ways:
  1. Prompt drift: a prompt changed since the cases testing it were authored.
  2. Coverage gaps: new prompts shipped with no cases covering them.
  3. Dead cases: cases point at prompts that no longer exist.
  4. Shape drift: the suite’s structure (cases, evaluators) changed out from under a pinned run.
  5. Threshold staleness: calibrated thresholds aging out as models or data shift.
staleness covers modes 1–3 only. Shape drift and threshold staleness are suite.lock territory (verify_suite_against_lock), and the CLI never claims them. The two drift detectors stay orthogonal by construction: stamping provenance never perturbs suite.lock, because the lockfile’s cases hash excludes metadata by design. A regression test pins that.

The three commands

multivon-eval staleness — the report

Read-only, zero-arg in a bootstrapped repo. Diffs a live attribution scan against the committed prompt_baseline.json and joins in per-case provenance:
Flags: --baseline FILE, --cases F.jsonl (repeatable), --suite module:attr (reads runtime metadata from Python-inline cases), --format text|json|markdown, --fail-on changed,removed,added, --include-tests, --ignore DIR (repeatable). By default the scan skips tests/, examples/, vendor/, and third_party/ — fixture SDK calls would flood the report.

multivon-eval staleness baseline — bless a snapshot

Fresh scan → prints the diff vs any existing baseline → writes atomically (temp file + os.replace). The name baseline rather than .lock is deliberate: this is a blessed snapshot you consciously refresh, not a regenerated fingerprint that must verify. Bootstrap writes one automatically.

multivon-eval staleness stamp — bind cases to sites

Binds hand-written JSONL cases to the prompt call site they exercise. The --site spec (FILE[::QUALNAME][.ROLE[#POS]]) is resolved against a live scan. Zero or multiple matches is an error listing candidates, never a guess, and a duplicated prompt fingerprint requires an explicit qualname anchor. Select cases with --index N (repeatable), --tag T, or --all; --dry-run and --force (overwrite a malformed/newer existing stamp) round it out.
The rewrite is raw-line-preserving: each line goes json.loads → inject metadata._provenancejson.dumps of the same dict. It never round-trips through load_jsonl (which would drop expected_tool_calls). Idempotent restamps are byte-identical, so no git churn. Restamps without an --evidence pointer after a prompt change are flagged in the report: self-attestation is visible, not silent.

The two artifacts

prompt_baseline.json — the repo-level scan snapshot, committed at the repo root:
Prompt text is deliberately not stored. git show <sha>:<file> recovers it, and a stored copy would be a second prompt that itself drifts. metadata["_provenance"] — per-case, inline in the existing free-form metadata dict under a library-reserved underscore key (no new EvalCase field):
targets: [] means “authored against repo state SHA X” and nothing more. bound is always "manual" in v1: auto-binding is rejected, because confidently-wrong links poison every downstream verdict. A schema_version from a newer release makes the case unreadable: counted, never fatal, exit code unaffected. A newer teammate’s stamp must not break an older teammate’s CI.

How matching works

  • Content-first. A prompt’s identity is its fingerprint (a hash of its text). Line numbers and git SHAs are display-only, never matching inputs. A whitespace refactor of surrounding code or a rebase produces zero false staleness, and a reverted prompt is automatically unchanged again.
  • The dynamic gate fires first. A prompt the scanner can’t statically read is UNKNOWN forever rather than fake-fresh. Placeholder fingerprints prove only call shape, not content, so comparing them would report a totally rewritten constant as “fresh”. A formerly-static site that became dynamic is UNKNOWN (“prompt moved out of static reach”), never CHANGED, never REMOVED.
  • Structural rescue. If the fingerprint is gone, the matcher tries the structural anchor (file, qualname, sdk, role) → CHANGED; if only the loose (whitespace-collapsed) fingerprint still matches, it’s labeled formatting-only — flagged, never suppressed. If nothing matches, it’s REMOVED, always with the three-way caveat: feature removed, OR renamed+edited in one commit, OR moved beyond static reach. REMOVED is a prompt to investigate, never an auto-delete signal. There is no fuzzy text-similarity matching: rename+edit in one commit is statically unbridgeable, and the tool says so instead of guessing.

Getting cases stamped

1

Bootstrap cases — automatic

multivon-eval bootstrap --repo . writes prompt_baseline.json and stamps every generated case with authored_by="bootstrap", the repo SHA, and targets=[]. Bindings are never fabricated — bootstrap generates cases from your product description and traces, and knows nothing about call sites.
2

Hand-written JSONL — `staleness stamp`

Explicit, opt-in binding via --site, as above. This is what turns the coverage number from zero into something meaningful.
3

Python-inline cases — `provenance.stamp()`

The CLI can’t edit your source, so build the metadata at authoring time:
Then multivon-eval staleness . --suite eval_suite:suite reads the runtime metadata for reporting.
CSV-loaded cases are permanently unstamped, because load_csv reads no metadata. Documented limitation, not a roadmap item. Unstamped cases of any origin are first-class: counted and reported with a stamp hint, never guessed at.

CI integration

Default exit is 0 even with findings. CHANGED means “authored against an older prompt, re-run recommended”, never “failing”. Gate per-category when you’re ready:
Exit contract: 0 clean or report-only, 1 a --fail-on category fired, 2 warn-only (no baseline, unreadable baseline, scanner-version mismatch). Gating on added (uncovered new prompts) punishes adoption; it’s possible but not recommended. The one-line warn-only recipe for GitHub Actions:
--format json emits the full machine-readable report (per-site verdicts with a confidence field — exact, structural, moved, or ambiguous — so CI consumers can filter surfaced ambiguity).

Blind spots

The static scan:
  • sees kwarg-only anthropic/openai/litellm Python call sites only
  • does not see the OpenAI Responses API or positional message args
  • does not see prompts in YAML/Jinja/templates/files or prompt hubs
  • does not see non-Python services
This list prints at the bottom of every text and markdown report. Prompts the scanner cannot see can still be stamped with source: "external"; they report as UNVERIFIABLE, never orphaned. A file the scanner can see but not parse (syntax error, non-UTF8 encoding) reports as UNSCANNABLE since 0.11.1 (“file exists but could not be parsed — verdict unknown, NOT removed”), with a warning naming each file, a skipped_files list in the JSON report, and no --fail-on removed trip. Skipped files are a report-time concept, never written into baselines.

Runtime recordings

The determinacy gate (0.10.1) measured scanner v3 against five real repos: 20.9% of call sites are statically resolvable. The rest build prompts dynamically and are statically unbridgeable by construction. The runtime prompt recorder (shipped in 0.11.0, designed in multivon-eval#9) is the way past that ceiling: during an eval run, an opt-in interceptor records the rendered prompt text per call site, fingerprinted with the same fingerprint_text the static scanner uses. A **kwargs unpack the scanner can only report as UNKNOWN is, at call time, real kwargs with real text.

The three trust tiers

Recordings get three labeled tiers, never collapsed into one another. The report footer states all three verbatim:
  1. static — the scan proves the prompt text.
  2. runtime — recordings prove only the renderings observed, not all renderings. Variable renderings per site are a fingerprint SET, and every verdict speaks in OBSERVED k/N language: “current recordings matched k of N previously observed renderings.” A site is never called fresh because one rendering matched.
  3. templates / external prompts — deferred, unverifiable.

Recording a run

Or outside pytest, the context manager:
Mechanics, by design constraint:
  • Opt-in only, zero overhead when off. Importing multivon_eval performs NO patching; a fresh-interpreter subprocess test pins this. Recording method-wraps exactly the three SDK surfaces the static scanner knows (anthropic Messages.create, openai chat.completions.create, litellm.completion/acompletion): save original, wrap, restore byte-identical on exit. Missing SDKs are skipped silently.
  • Recordings stay local in prompt_recordings.jsonl; no telemetry. Fingerprints only by default — rendered text is stored only behind an explicit --record-text. Storage is append-safe: duplicate (anchor, role, fingerprint) keys merge counts and case_uids on write.
  • Recording happens BEFORE the SDK call executes, so prompts from calls that subsequently fail (timeout, auth error, rate limit) are still captured. A failing run leaves evidence, not a gap.
  • Case binding by observation. A contextvar carries the active case_uid: EvalSuite binds it per case from _provenance.case_uid, and the pytest plugin binds the test nodeid per test. Recordings carry the case_uids observed per (anchor, role, fingerprint), so the run knows which sites fired for which case.
  • Capture scope in v1: string system= kwargs and string content entries in messages= lists. Content-block lists (vision, tool results) and calls anchored outside the repo root are skipped, not guessed at.

Merging into the baseline

--merge-recordings is merge-only and needs an existing prompt_baseline.json — on a fresh repo it errors out (exit 2) with “no baseline at … — run multivon-eval staleness baseline . first”. Run the plain staleness baseline . static scan once before your first merge. Merges recordings into prompt_baseline.json as source:"runtime" records with fingerprint SETS, stored under a separate runtime_records key. Merge-only: it never rescans and NEVER touches static records; a static rescan never discards the runtime tier; re-merging the same recordings file is idempotent. Runtime-sourced sites then render as a distinct OBSERVED tier in all three report formats, compared recordings-vs-recordings and always in the k/N language. Runtime-only sites cannot be compared against a static scan, and the report says so. The determinacy headline gains a third clause: “K sites observed at runtime.”

Stamping from recordings

--from-recordings prints observed case→site bindings as proposals (case_uid → anchor + fingerprint with observation counts); it writes only with explicit --apply --cases F.jsonl, landing targets as source:"runtime", bound:"observed". Observation removes the fabrication objection that blocked auto-binding in the 0.10.0 adversarial review, while the human confirmation stays. Runtime-bound targets are verified against recordings, never against the static scan (where they report unverifiable [runtime], by rule), and never enter the static coverage denominator.

What’s deferred

  • sync — propose-and-review case refresh that consumes the staleness JSON. Never auto-commit, by design. Tracked in multivon-eval#8.
  • eval-action enforcement — the staleness gate as an Action input with per-category fail-on; today the GITHUB_STEP_SUMMARY line above is the documented warn-only path. Tracked in eval-action#1.
(The runtime recorder, originally on this list, shipped in 0.11.0 — see Runtime recordings above.)

See also

  • Prompt attribution — the scanner this is built on, including the v3 detection fixes (aliased/**kwargs/messages=<var> shapes) and the v4 hardening (NFC-normalized fingerprints, UNSCANNABLE).
  • Bootstrap — writes the baseline and stamps generated cases automatically via --repo.
  • /eval-audit skill — the per-PR audit; staleness is the standing drift report between PRs.
  • CI/CD integration — wiring multivon-eval into GitHub Actions.