Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 84 additions & 4 deletions rivet-cli/src/check/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use rivet_core::cited_source::{
self, CheckOutcome, CitedSource, check_cited_source, parse_cited_source,
self, CheckOutcome, CitedSource, STALE_DAYS_DEFAULT, StaleStatus, check_cited_source,
classify_staleness_now, parse_cited_source,
};
use rivet_core::model::Artifact;
use serde::Serialize;
Expand Down Expand Up @@ -67,6 +68,42 @@ impl EntryStatus {
}
}

/// Side-channel staleness report — orthogonal to `EntryStatus` because a
/// `MATCH` entry can still be stale (last-checked is old or missing).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StaleVerdict {
Fresh,
MissingTimestamp,
Old,
Unparseable,
}

impl StaleVerdict {
fn label(self) -> &'static str {
match self {
StaleVerdict::Fresh => "FRESH",
StaleVerdict::MissingTimestamp => "STALE-MISSING",
StaleVerdict::Old => "STALE-OLD",
StaleVerdict::Unparseable => "STALE-UNPARSEABLE",
}
}

fn from_status(s: StaleStatus) -> Self {
match s {
StaleStatus::Fresh => StaleVerdict::Fresh,
StaleStatus::Missing => StaleVerdict::MissingTimestamp,
StaleStatus::Old { .. } => StaleVerdict::Old,
StaleStatus::Unparseable => StaleVerdict::Unparseable,
}
}

/// True for any non-fresh verdict — used by `--strict` exit code.
pub fn is_stale(self) -> bool {
!matches!(self, StaleVerdict::Fresh)
}
}

#[derive(Debug, Clone, Serialize)]
pub struct Entry {
pub artifact_id: String,
Expand All @@ -79,6 +116,13 @@ pub struct Entry {
pub computed_sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_checked: Option<String>,
/// Side-channel: `last-checked` freshness verdict. Always emitted
/// for kind: file entries; omitted (None) for shape-errors and remote-skipped.
#[serde(skip_serializing_if = "Option::is_none")]
pub stale: Option<StaleVerdict>,
/// Age in days when `stale = Old`. None otherwise.
#[serde(skip_serializing_if = "Option::is_none")]
pub stale_age_days: Option<i64>,
/// File path on disk for `kind: file` entries (for `--update`).
#[serde(skip)]
pub source_file: Option<PathBuf>,
Expand All @@ -95,6 +139,7 @@ pub struct StatusCounts {
pub read_error: usize,
pub skipped_remote: usize,
pub shape_error: usize,
pub stale: usize,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -132,6 +177,8 @@ pub fn compute<'a>(
stamped_sha256: None,
computed_sha256: None,
last_checked: None,
stale: None,
stale_age_days: None,
source_file: artifact.source_file.clone(),
detail: Some(e.to_string()),
});
Expand Down Expand Up @@ -161,6 +208,24 @@ pub fn compute<'a>(
EntryStatus::ShapeError => by_status.shape_error += 1,
}

// Compute the staleness side-channel for kind: file. Remote
// kinds skip this — we can't reason about freshness without
// the actual backend.
let (stale, stale_age_days) = if parsed.kind.is_local() {
let s = classify_staleness_now(parsed.last_checked.as_deref(), STALE_DAYS_DEFAULT);
let age = match s {
StaleStatus::Old { age_days } => Some(age_days),
_ => None,
};
let v = StaleVerdict::from_status(s);
if v.is_stale() {
by_status.stale += 1;
}
(Some(v), age)
} else {
(None, None)
};

entries.push(Entry {
artifact_id: artifact.id.clone(),
uri: parsed.uri.clone(),
Expand All @@ -169,6 +234,8 @@ pub fn compute<'a>(
stamped_sha256: parsed.sha256.clone(),
computed_sha256: computed,
last_checked: parsed.last_checked.clone(),
stale,
stale_age_days,
source_file: artifact.source_file.clone(),
detail,
});
Expand All @@ -191,19 +258,31 @@ pub fn render_text(report: &Report) -> String {
out.push_str("No artifacts have a cited-source field.\n");
return out;
}
let _ = writeln!(out, "{:<14} {:<14} {:<8} URI", "ARTIFACT", "STATUS", "KIND",);
let _ = writeln!(
out,
"{:<14} {:<14} {:<18} {:<8} URI",
"ARTIFACT", "STATUS", "FRESHNESS", "KIND",
);
for e in &report.entries {
let stale_label = e.stale.map(|s| s.label()).unwrap_or("-");
let _ = writeln!(
out,
"{:<14} {:<14} {:<8} {}",
"{:<14} {:<14} {:<18} {:<8} {}",
e.artifact_id,
e.status.label(),
stale_label,
e.kind,
e.uri
);
if let Some(detail) = &e.detail {
let _ = writeln!(out, " detail: {detail}");
}
if let Some(age) = e.stale_age_days {
let _ = writeln!(
out,
" last-checked age: {age} day(s) (threshold: {STALE_DAYS_DEFAULT})"
);
}
if let (Some(stamped), Some(computed)) = (&e.stamped_sha256, &e.computed_sha256) {
if e.status == EntryStatus::Drift {
let _ = writeln!(out, " stamped : {stamped}");
Expand All @@ -214,14 +293,15 @@ pub fn render_text(report: &Report) -> String {
let _ = writeln!(out);
let _ = writeln!(
out,
"Total: {} (match: {}, drift: {}, missing-hash: {}, read-error: {}, skipped-remote: {}, shape-error: {})",
"Total: {} (match: {}, drift: {}, missing-hash: {}, read-error: {}, skipped-remote: {}, shape-error: {}, stale: {})",
report.total,
report.by_status.r#match,
report.by_status.drift,
report.by_status.missing_hash,
report.by_status.read_error,
report.by_status.skipped_remote,
report.by_status.shape_error,
report.by_status.stale,
);
if report.by_status.skipped_remote > 0 {
let _ = writeln!(
Expand Down
84 changes: 79 additions & 5 deletions rivet-cli/src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1959,7 +1959,10 @@ Rule names emitted by `rivet validate`:
- `cited-source-drift` (Warning by default; Error with
`--strict-cited-sources`) — sha256 stamp does not match the file on
disk, or the stamp is missing entirely.
- `cited-source-stale` (Info) — `last-checked` is missing.
- `cited-source-stale` (Info by default; Error with
`--strict-cited-source-stale`) — `last-checked` is missing,
unparseable, or older than 30 days. The 30-day threshold is a global
default; per-schema overrides are deferred to a follow-up feature.
- `cited-source-skipped` (Info, only with `--check-remote-sources`) —
remote kind acknowledged but not yet verified (Phase 1).

Expand Down Expand Up @@ -1987,21 +1990,49 @@ rivet validate
# Treat cited-source-drift as a hard failure (CI gate).
rivet validate --strict-cited-sources

# Treat cited-source-stale (last-checked > 30d, missing, or unparseable)
# as a hard failure. Pair with --strict-cited-sources for a full audit
# gate.
rivet validate --strict-cited-source-stale

# Phase 2 — flag accepted by Phase 1, no-op for now.
rivet validate --check-remote-sources

# Audit & refresh workflow.
rivet check sources # list every cited-source + status
rivet check sources --update # interactive y/N per drift
rivet check sources --update --apply # batch refresh

# Read-only audit gate. Exit 1 on any drift / missing-hash / read-error
# / shape-error / stale (last-checked > 30d). Never modifies any YAML.
# Mutually exclusive with --update — audit and fix are separate
# invocations so CI never sees a "ran-then-fixed" mutation pattern.
rivet check sources --strict
```

## Audit gate pattern (issue #249)

For platform engineers who want a clean read-only audit gate in CI:

```bash
# In CI: read-only check. Fails if any cited-source has drifted, is
# missing a hash, has a read error, or has a stale last-checked.
rivet check sources --strict

# Equivalent gate via `rivet validate`:
rivet validate --strict-cited-sources --strict-cited-source-stale
```

The `check sources --strict` form emits a richer per-artifact table
(useful for debugging which file drifted); the `validate` form is
preferred when you want a single command to gate the whole project.

## last-checked semantics

`last-checked` is a stamp, not a verification gate. Phase 1 emits an
`Info`-severity `cited-source-stale` diagnostic when the field is
absent; future phases may add a per-schema staleness threshold (`>30
days for fast-moving sources`) if real-world demand justifies it.
`last-checked` is a stamp **and** a freshness signal. The
`cited-source-stale` Info diagnostic fires when `last-checked` is
missing, unparseable, or older than the threshold (30 days by default
in v0.7.x). Per-schema thresholds are deferred to a follow-up feature.

The `rivet check sources --update --apply` flow always rewrites
`last-checked` to the current UTC time when it touches an artifact.
Expand Down Expand Up @@ -2607,6 +2638,8 @@ the conflict in-place and runs `--continue`, or drops the artifact with
## Quick start

```
rivet schema migrate --list # enumerate available recipes
rivet schema migrate --list --format json # same, machine-readable
rivet schema migrate aspice # plan only (dry-run)
rivet schema migrate aspice --apply # apply; pause on first conflict
rivet schema migrate aspice --continue # resume after editing markers
Expand All @@ -2619,6 +2652,47 @@ rivet schema migrate aspice --abort # restore everything from snapshot

The default invocation is plan-only and never modifies the project tree.

## Discovering recipes (`--list`)

`rivet schema migrate --list` walks the recipe registry and prints one
row per available recipe (built-in + every YAML under
`<schemas-dir>/migrations/`). Project-local recipes shadow built-ins
of the same name. The flag is target-free and read-only — always exits
0.

```
NAME ORIGIN SOURCE TARGET DESCRIPTION
dev-to-aspice built-in dev aspice Mechanical mapping for the most common dev -> aspice transition.
dev-to-stpa project-local dev stpa Custom recipe for our STPA workflow.
path: schemas/migrations/dev-to-stpa.yaml

Total: 2 (built-in: 1, project-local: 1)
```

JSON output (`--format json`) emits the same data in the
`schema-migrate-recipes` oracle shape:

```json
{
"oracle": "schema-migrate-recipes",
"recipes": [
{
"name": "dev-to-aspice",
"source_preset": "dev",
"target_preset": "aspice",
"description": "...",
"origin": "built-in",
"path": null
}
],
"warnings": [],
"total": 1
}
```

`--list` is mutually exclusive with the action flags (`--apply`,
`--abort`, `--status`, `--finish`, `--continue`, `--skip`, `--edit`).

## State machine

```
Expand Down
Loading
Loading