Drift Watch — Staleness & Source-Agreement Report
Paths.
analysis-results/…andprogress-tracker/…in this skill are the default workspace layout. They resolve throughlocations.yamlin$TRAUST_CONFIG_HOME(docs/setup.md, Storage locations); substitute your configured roots.
Everything derived can silently age, and any two sources describing the
same fact can silently diverge. This skill makes both visible on a
cadence and files the evidence where campaign reporting lives:
progress-tracker/metrics/drift/drift-report.{json,md}.
Distinct from the retired C3 delta-watch idea (re-auditing changed target repos — superseded by Konflux): drift-watch checks the harness's own artifacts and source pairs, not audit targets.
Procedure
Run the checker (deterministic; one pass, all checks, skip-and-record per item):
python3 -m traust.cli check drift [--fail-on stale|drift]Read the report back. Lead with
driftitems, thenstale, thenreview_due;pendingitems are gaps that will close as planned phases land (say which phase); a fully-freshreport is the answer "nothing is stale", not a wasted run.For each actionable item, offer the refresh command from the report — but do not execute rebuilds unprompted; the checker routes attention, the human decides (rebuild timing can matter mid-campaign).
Commit the report to progress-tracker so trends in drift are themselves visible in git history.
What it checks
| Item | Staleness / drift signal |
|---|---|
feeds:* |
pull-through cache age vs each source's threshold, for every tier: cached security source in config/feeds.yaml: epss, kev, rh-cve, vex. 48h — one missed run of the daily feed router plus slack |
registry:product-definitions |
a reference registry, not a security feed — Red Hat Product Security's product/ownership data, absent from config/feeds.yaml by design. The registry: prefix is derived from that absence, not hardcoded, so the row name follows the category rather than the fetch mechanism. VPN-only and pull-through, so it refreshes at 24h but is only flagged at 168h; never fetched is pending, not unavailable, so an optional source stays out of --fail-on |
findings.db |
built_at vs newest ledger/report mtime |
ledger:* |
does the ledger actually COVER the corpus — nothing asked this before. audits-without-a-layer (an audited repo with no disposition layer), layers-without-a-report-digest, incomplete-claim-hashes, and the informational colliding-layer-ids. Signature coverage and the findings-db projection were both guarded; whether a ledger EXISTS per audited repo was not |
ledger-signature-coverage |
disposition layers that are Merkle-rooted AND signed vs the total. pending when signing is not configured, drift when rooted-but-unsigned layers exist — an unsigned layer is a disposition history nothing attests to |
fp-precedent-cache |
metadata.generated vs newest *-findings-layer.json mtime — new countersign/triage/validation adjudications after the build mean /triage 2g and the audit fp-precedent gate route on stale precedent; rebuild via build_fp_precedent_cache.py build |
rule-mining |
progress-tracker/metrics/rule-mining/rule-mining.json age vs the newest *-findings-layer.json mtime across analysis-results — stale when ledger changes are >7 days newer (RULE_MINING_LAG_DAYS, the weekly-lane grace window): a large audit/triage wave lands confirmations no rule has seen. This row is the rule-mining lane's post-wave trigger; refresh via python3 -m traust.cli sweep rule-lane (see the mine-ledger SKILL's Scheduled lane) |
repo-graph, portfolio-graph |
build stamp vs the inputs inventory's HEAD (locations.inputs) — inputs is the sole topology authority; a moved HEAD means inventory/ownership may have changed under the graphs |
validation-benchmark |
scorecard age vs the 35-day monthly cadence (P7 hybrid; the release-cut leg is run_validation_benchmark.py check-trigger) |
impact:<cve> |
each analysis-results/impact/*-impact-analysis.json vs the portfolio-graph build it queried — a graph rebuilt after the artifact means the blast radius may have shifted; re-run /impact-analysis |
repo-liveness |
artifact age |
corpus-registration |
output trees on disk vs corpus-config (unregistered trees = drift) |
ref-provenance |
slug-derived ref vs declared metadata.ref on every corpus report — a report carrying both must agree (disagreement = drift: stale slug after a move/rename, or a wrong-checkout stamp; branch-awareness Phase 4). Reports carrying only one form are not comparable and stay silent |
adr-registry:* |
each register's pinned SHA vs live upstream HEAD (pending until compliance Phase 2c) |
checkov-pin |
age of PINNED_CHECKOV_VERSION in cloud-config-audit's run_checkov.py on PyPI — Checkov's ~1000 policies ship inside the release, so an aging pin means aging cloud-config policies (age-based, not version-lag: Checkov ships several patch releases a week) |
yara-rules-pin |
pinned ReversingLabs YARA rules SHA vs upstream develop HEAD. The malware-family pack ages FASTER than the yara engine whose floor external-tools:yara watches: a frozen pin silently stops matching NEW families |
argus-rules-pin |
pinned argus-observe-rules SHA vs upstream main HEAD. An OPT-IN crypto/PQC supplement, not the default pack — a stale pin costs coverage on a lane nobody is forced to run, so it is watched but is not scan degradation |
external-tools:* |
each unpinned PATH-invoked deterministic scanner from the config/external-tools.yaml manifest (roster + expected floors, shared with the orchestrator job-image build): installed below the manifest's expected = drift (environment disagrees with declared config); installed behind the latest upstream release past the grace window (EXTERNAL_TOOL_LAG_GRACE_DAYS) = stale — detection content (detectors, matchers, ecosystems) ages with the install. Not-installed reports pending, unstamped v0.0.0 go builds report unavailable, an unloadable manifest is one loud unavailable row. Checkov is excluded (covered by checkov-pin); advancing expected or upgrading remains a deliberate config change — re-verify one known-good target before a sweep |
feed-source:* |
liveness of every source in config/feeds.yaml carrying a probe — both tiers. The live tier previously had no freshness signal at all: fetch_advisory.py csaf requested advisories/<cve>.json and 404'd for every CVE ever passed to it (Red Hat keys CSAF advisories by RHSA, not CVE) and nothing failed, because a hardcoded URL has no registry to inspect. A definitive HTTP error = drift (registry and service disagree about the endpoint's shape; refresh line says fix the url_template); a transport failure = unavailable, never drift, so an offline workstation does not report every source broken. Status only — the response body is never read |
grype-db |
grype's vulnerability DB content age, not its binary version — external-tools:grype watches the binary and grype ships matchers separately, so a current grype can carry a months-old DB while that row stays green (measured 2026-08-25: grype 0.117.0 == latest, DB built 2026-07-30, Status: invalid). Threshold is grype's OWN 5-day max age (GRYPE_DB_MAX_AGE_DAYS) — past it grype invalidates the DB itself — and the row fires on age even while grype still reports valid. Not-installed = pending; an unparseable grype db status = unavailable, never fresh. Refresh is grype db update, then re-run any container/SBOM scan whose findings must reflect current CVE data |
pqc-facts-stamps |
reproducibility stamps in every corpus *-pqc-facts.json vs the current ADAPTER_VERSION / PQC_SCAN_COMMIT pins in pqc_facts.py — the write-time schema gate protects new writes only; when the adapter or scanner pin moves, this makes the aging corpus visible (stale facts self-heal via re-scans). Head-read of the stamps block, O(corpus) cheap |
pqc-facts-schema |
newest PQC_FACTS_SCHEMA_SAMPLE facts files vs contracts/schemas/pqc-facts.schema.json — bounded sample (new files can't violate thanks to the write gate); a violation means the schema was tightened after the fact, and the corpus needs a sweep before its consumers (/patch pqc ingest, L2 prepass) trip on it |
pqc-backfeed |
count of portfolio-graph repo nodes carrying $.pqc vs the pqc corpus that feeds it (pqc-backfeed-summary.json:repos_with_pqc_attrs, else the *-pqc-facts.json corpus count). Below PQC_BACKFEED_MIN_RATIO (50%) of the corpus = drift — the graph-rebuild clobber signature that once dropped 3142 stamped repos to 41 and zeroed the per-product PQC reports (build_pqc_product_reports.py requires json_extract(attrs,'$.pqc') IS NOT NULL). The structural fix is build_portfolio_graph.ENRICHMENT_ATTR_KEYS; this row is the backstop. Read-only; pending when portfolio-graph.db or the corpus is absent. Refresh = re-run scan_pqc_dependencies.py |
identity:* |
the corpus-wide fingerprint sweep — every finding in every *-security-audit.json, recomputed against the installed traust_ledger.identity recipe. unstamped-findings = drift (a finding with no fingerprint cannot be matched to its own history on re-audit); non-reproducing-stamps = drift (a value the recipe does not produce: not written by traust_ledger.identity, edited under, or missed by an ALGO_VERSION re-stamp — the remedy is to LOOK, since re-stamping changes identity and orphans disposition history); degenerate-findings = stale, the burndown counter for findings whose locations all canonicalize to empty, which is what gates the fingerprint(strict=True) flip (plan item 4b); colliding-fingerprints = info, findings sharing an identity with another finding in the SAME report — the false-MERGE dual of the rows above and invisible to them, since both stamps recompute perfectly and simply name the same thing. Informational rather than actionable because it is a property of the recipe (repo+paths+primary CWE distinguishes nothing between two same-file same-CWE findings), not a regression: it is contained today by dispositions keying on (layer, finding_ref) rather than identity (D9) and by rebaseline tier-1 refusing a non-unique candidate, and it bites only where something DOES key on the fingerprint. Degenerate findings are excluded so the two rows never report one defect twice. Why it belongs here: every other identity gate lives in the producing session (the audit skill's stamp step, then the validator's error on an absent or non-reproducing value), and analysis-results has no hook and no CI — so an artifact written outside the skill path meets no gate on the way in. ~3s over 8k reports; unavailable rather than silent when a report will not parse |
framework-spine:upstream |
vendored 800-53/800-53B derivations vs the pinned usnistgov/oscal-content commit — a moved upstream means NIST may have revised the catalog (review before re-deriving) |
provenance:* |
dated policy values (SLA policy, compliance spine, org-parameters, and the PCI/SOC 2/GDPR ids-only catalogs' reviewed dates) past the 180-day review window — frameworks without machine feeds are staleness-checked through their review dates |
rescan-worklist |
analysis-results/findings/_manifest/rescan-worklist.json missing or generated_at older than 3 days — the continuous-operations router (python3 -m traust.cli build rescan-worklist, docs/continuous-operations.md) is the daily authority on rescan frequency; a stale worklist means scan dispatch is flying blind on old change data |
threat-model |
backstop for the re-model cadence (shipped 2026-08-05): three router lanes refresh models — a /threat-model pr stamp on diff rows, a release-review on major/minor events, and a 92-day quarterly sweep. This row is the dead-timer guard on that last one: any model older than 92d is stale |
language-coverage:* |
portfolio language composition vs dependency-graph ecosystem coverage — the anti-Go-bias tripwire. Loads the portfolio language cache (analysis-results/findings/_manifest/portfolio-lang/gh-languages-cache-merged.jsonl) and counts the repos each GitHub language is dominant in (max bytes). A language dominant in >= LANGUAGE_COVERAGE_THRESHOLD (default 10) repos that maps to NEITHER a covered ecosystem (manifest_parsers.LANGUAGE_ECOSYSTEMS/the universal docker·actions·helm surfaces/the GRAPHED_NON_MANIFEST_LANGUAGES carve-out for Go's go.mod L1 lane) NOR the explicit NO_DEPS_LANGUAGE_ALLOWLIST (shells, markup/templating/query languages, IaC/policy surfaces owned by other lanes) is a NEW language the graph is silently under-covering (Swift/Elixir/PHP/Kotlin/Scala if uncovered) = drift — same philosophy as the docs-product-map FUTURE-version check. When analysis-results/graph/deps-multi-stats.json exists it also emits one informational row per ecosystem (language-coverage:eco:<eco> — repos_with_manifest, dep_edges) and flags any ecosystem whose manifests were discovered (repos_with_manifest > 0) but yielded 0 dep_edges = drift (a silently-broken extraction lane / coverage regression, mirroring the builder's loud_fail). Extend the allowlist (with a reason) rather than lowering the threshold |
language-cache-freshness |
age of the gh-languages cache that language-coverage:* reads. That cache is not refetched on cadence, so a stale one lets the tripwire run on old data and miss a genuinely new language entering the portfolio. /loc-dashboard (with fetch) refreshes it |
reachability-coverage:* · reachability-watch:review |
portfolio language mass vs symbol-tier reachability coverage — the sibling of language-coverage:* one rung up the evidence ladder (that row asks whether a language's dependency graph is built; this one asks whether a finding in it can ever reach affected). A language dominant in >= REACHABILITY_COVERAGE_THRESHOLD (default 50) repos with no entry in check_drift.REACHABILITY_ENGINES and no NO_DEPS_LANGUAGE_ALLOWLIST excuse = drift, reported with its repo mass so the priority ordering is visible; without an engine every dependency finding in it ceilings at likely_affected on manifest evidence ("a pin is never reachability", docs/reachability.md). Wiring an engine means adding its language to REACHABILITY_ENGINES — an un-updated map keeps flagging (loud) rather than silently marking a language covered. reachability-watch:review is the review clock on progress-tracker/configs/reachability-watch.yaml, whose revisit triggers (does OSV carry call-graph data yet; has a permissively-licensed engine matured) have no machine feed, so the review window IS the staleness mechanism — same convention as the compliance catalogs' reviewed keys. Scoped pilot for the currently-flagged languages: progress-tracker/plans/joern-python-js-reachability-pilot-plan.md |
dependency-pins:* |
installed sibling-package versions vs the git-tag pins in pyproject.toml [tool.uv.sources] (traust-engine, traust-ledger, traust-contracts). Absent = drift (NOT INSTALLED), wrong version = drift (VERSION SKEW), fix is uv sync. Replaced the submodules:* row when the C8 restructure swapped those submodules for pip deps — the failure class did not go away, only its delivery: both are "a dependency is not the version this tree expects", surfacing as an import error naming neither the dependency system nor the fix (2026-08-13: 18 test modules died at collection after a traust-ledger bump; 2026-08-14: traust_engine simply absent until uv sync). CI cannot cover it — CI builds its own venv, while skills run on the operator workstation. Fails open (unavailable) on an unreadable pyproject |
sibling-readme:* |
each sibling repo's README vs that repo's own pyproject and module surface — the same failure class as dependency-pins:* one level out: a documented claim reality has moved past. Measured on traust-ledger 2026-08-17 |
dashboards:* |
generated dashboards refresh on demand, not on a schedule — harness-scoreboard (Generated stamp > 7d), spend (stamp > 4d), and spend-actuals (newest session-actuals row > 4d — a rebuilt dashboard can hide an un-appended ledger). Backstop until the refreshes fold into a scheduled loop — the packaged refresh command is /refresh-dashboards (python3 -m traust.cli dashboard refresh); the 2026-07-24 scoreboard (6 days / 88 versions stale) is the motivating incident |
docs-semantic-sweep |
age of the newest progress-tracker/gap-assessments/docs-verification-*.md. The mechanical doc gates catch counts, links, flags and enums — not behavioral claims, which are bounded by a quarterly reviewer sweep. Stale past 92d |
docs-versions:* |
the doc-variance lane's version watch: each product's declared docs-product-map versions vs its live docs.redhat.com landing page. A NEW upstream version missing from the map is drift (variance tracking is not covering it); a retired one is drift too; below-window historical versions are ignored and a network failure is unavailable, never fresh |
New source pairs (IaC-source pin vs inputs, config-vs-cluster once
live inventories exist) register as new check functions in
check_drift.py — one function per pair, statuses only from the fixed
vocabulary.
Cadence
On demand any time; recommended alongside every /census run; a
scheduled run is Konflux fragment 6 (capability-roadmap C3 list)
when that integration lands. --fail-on drift makes it CI-gate-able.
This skill's place among the harness's standing jobs — and the jobs
whose staleness its rows backstop — is inventoried in
docs/continuous-operations.md ("Standing jobs — the complete inventory").
Constraints
- Deterministic checker: routes attention, never concludes — no check may auto-rebuild, auto-advance a pin, or file a finding.
- Drift between two sources is reported symmetrically; the checker never picks a winner (precedence rules live in the compliance plan's Graph-wiring section, and applying them is a human/skill decision).
- Thresholds are constants in the script header — change them there with review, not per-run.
Also checked: docs-semantic-sweep — the newest progress-tracker/gap-assessments/docs-verification-*.md report must be younger than 92 days (quarterly semantic docs sweep; procedure in the check-harness-docs SKILL).
Integrations
Consumes: the artifacts it watches — feeds, findings.db, repo-/portfolio-graph vs the inputs inventory, corpus registration, ADR pins, external-tools manifest, pqc-facts stamps, dated policies (the full row list lives in python3 -m traust.cli check drift).
Emits: drift-report.{json,md} into
progress-tracker/metrics/drift/ — a terminal operator dashboard; its
stale rows route work to the owning skills (e.g. dashboards:* rows →
/refresh-dashboards).