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
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sessionId":"cb60aa6b-b112-4d31-8e53-2ac3e19a3ff7","pid":73847,"procStart":"Fri May 29 16:53:25 2026","acquiredAt":1780208158852}
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@

### Fixed

- **Issue #349 — `required-backlink` rules now match the inverse-name
convention.** Schemas (e.g. `safety-case.yaml`) declare
`required-backlink: supported-by` — the *inverse* of the forward
`supports` link — and both `rivet validate` and `rivet coverage`
compared that name against the stored `Backlink.link_type`, which
holds the *forward* name. Result: GSN safety-case rules like
`goal-has-support` fired (and counted as uncovered) for every
artifact, even when the supporting solution was correctly linked.
Match now accepts either the forward or the inverse name, so
both conventions (`dev.yaml` uses forward, `safety-case.yaml` uses
inverse) validate consistently. Same fix path additionally evaluates
`alternate-backlinks` in both engines — previously a goal satisfied
only via an alternate (e.g. `decomposed-by` instead of
`supported-by`) was reported as missing.
- **REQ-110 / REQ-111 — coverage "totals" no longer masquerade as artifact
counts.** The dashboard overview rendered `{covered} / {total} artifacts
covered` and the `coverage --format json` `overall` object exposed
Expand Down
186 changes: 170 additions & 16 deletions rivet-core/src/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,22 +191,40 @@ pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> Co
.is_some_and(|a| target_types.contains(&a.artifact_type))
}
}),
CoverageDirection::Backward => graph
.backlinks_to(id)
.iter()
// Same reasoning as forward: a backlink from the artifact
// to itself (self-referential link) cannot count as
// "satisfied by a different artifact."
.filter(|bl| bl.link_type == link_type && bl.source != *id)
.any(|bl| {
if target_types.is_empty() {
true
} else {
store
.get(&bl.source)
.is_some_and(|a| target_types.contains(&a.artifact_type))
}
}),
CoverageDirection::Backward => {
// Schemas write the backlink name as either the forward
// link-type (e.g. `satisfies`) or the inverse
// (e.g. `supported-by`); accept either. `alternate-backlinks`
// adds further acceptable shapes (e.g. a safety-goal that
// is `supported-by` OR `decomposed-by` OR `has-sub-goal`).
let backlinks = graph.backlinks_to(id);
let backlink_matches = |link_name: &str, from_types: &[String]| {
backlinks
.iter()
// Same reasoning as forward: a backlink from the
// artifact to itself (self-referential link) cannot
// count as "satisfied by a different artifact."
.filter(|bl| bl.source != *id)
.filter(|bl| {
bl.link_type == link_name
|| bl.inverse_type.as_deref() == Some(link_name)
})
.any(|bl| {
if from_types.is_empty() {
true
} else {
store
.get(&bl.source)
.is_some_and(|a| from_types.contains(&a.artifact_type))
}
})
};
backlink_matches(&link_type, &target_types)
|| rule
.alternate_backlinks
.iter()
.any(|alt| backlink_matches(&alt.link_type, &alt.from_types))
}
};

if has_match {
Expand Down Expand Up @@ -644,4 +662,140 @@ mod tests {
);
assert_eq!(entry.total, 1);
}

/// Issue #349: schemas write `required-backlink` as either the forward
/// link-type name (`supports`) or its inverse name (`supported-by`).
/// safety-case.yaml uses the inverse-name convention. With the bug,
/// no artifact was ever counted as covered for such rules because the
/// stored `Backlink.link_type` is the forward name. This regression
/// test pins the fix: both conventions must produce identical coverage.
///
/// rivet: fixes REQ-004
#[test]
fn required_backlink_matches_inverse_link_type_name() {
use crate::schema::LinkTypeDef;
let mut file = minimal_schema("test");
// Declare the link type with its inverse — mirrors safety-case.yaml.
file.link_types.push(LinkTypeDef {
name: "supports".into(),
inverse: Some("supported-by".into()),
description: "Solution supports goal".into(),
source_types: vec!["safety-solution".into()],
target_types: vec!["safety-goal".into()],
});
file.traceability_rules = vec![TraceabilityRule {
name: "goal-has-support".into(),
description: "Every safety goal must be supported by evidence".into(),
source_type: "safety-goal".into(),
required_link: None,
// INVERSE name — the case that was silently broken.
required_backlink: Some("supported-by".into()),
target_types: vec![],
from_types: vec!["safety-solution".into()],
severity: Severity::Error,
alternate_backlinks: vec![],
}];
let schema = Schema::merge(&[file]);

let mut store = Store::new();
store
.insert(minimal_artifact("SG-1", "safety-goal"))
.unwrap();
// SOL-1 has the FORWARD link `supports → SG-1`. The auto-computed
// backlink to SG-1 stores `link_type = "supports"` and
// `inverse_type = Some("supported-by")`.
store
.insert(artifact_with_links(
"SOL-1",
"safety-solution",
&[("supports", "SG-1")],
))
.unwrap();

let graph = LinkGraph::build(&store, &schema);
let report = compute_coverage(&store, &schema, &graph);
let entry = report
.entries
.iter()
.find(|e| e.rule_name == "goal-has-support")
.expect("rule should produce a coverage entry");

assert_eq!(
entry.covered, 1,
"SG-1 is supported-by SOL-1 (via the forward `supports` link); \
coverage must count it even though the rule names the inverse"
);
assert!(entry.uncovered_ids.is_empty(), "no goals are uncovered");
}

/// Issue #349 secondary: `alternate-backlinks` were never evaluated.
/// Safety-case schemas express "supported-by OR decomposed-by OR
/// has-sub-goal" via this field; an artifact satisfied only via an
/// alternate must still count as covered.
///
/// rivet: fixes REQ-004
#[test]
fn coverage_honours_alternate_backlinks() {
use crate::schema::{AlternateBacklink, LinkTypeDef};
let mut file = minimal_schema("test");
file.link_types.push(LinkTypeDef {
name: "supports".into(),
inverse: Some("supported-by".into()),
description: "Solution supports goal".into(),
source_types: vec!["safety-solution".into()],
target_types: vec!["safety-goal".into()],
});
file.link_types.push(LinkTypeDef {
name: "decomposes".into(),
inverse: Some("decomposed-by".into()),
description: "Strategy decomposes a goal".into(),
source_types: vec!["safety-strategy".into()],
target_types: vec!["safety-goal".into()],
});
file.traceability_rules = vec![TraceabilityRule {
name: "goal-has-support".into(),
description: "Every goal supported OR decomposed".into(),
source_type: "safety-goal".into(),
required_link: None,
required_backlink: Some("supported-by".into()),
target_types: vec![],
from_types: vec!["safety-solution".into()],
alternate_backlinks: vec![AlternateBacklink {
link_type: "decomposed-by".into(),
from_types: vec!["safety-strategy".into()],
}],
severity: Severity::Error,
}];
let schema = Schema::merge(&[file]);

let mut store = Store::new();
// SG-A is decomposed (alternate) — no `supports` backlink. Must
// still count as covered.
store
.insert(minimal_artifact("SG-A", "safety-goal"))
.unwrap();
store
.insert(artifact_with_links(
"STRAT-1",
"safety-strategy",
&[("decomposes", "SG-A")],
))
.unwrap();
// SG-B has neither — uncovered.
store
.insert(minimal_artifact("SG-B", "safety-goal"))
.unwrap();

let graph = LinkGraph::build(&store, &schema);
let report = compute_coverage(&store, &schema, &graph);
let entry = report
.entries
.iter()
.find(|e| e.rule_name == "goal-has-support")
.expect("rule should produce a coverage entry");

assert_eq!(entry.covered, 1, "SG-A covered via alternate backlink");
assert_eq!(entry.uncovered_ids, vec!["SG-B"]);
assert_eq!(entry.total, 2);
}
}
161 changes: 153 additions & 8 deletions rivet-core/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,15 +901,32 @@ pub fn validate_structural_with_externals_and_variant(

// Backlink check (coverage). Empty `from_types` means "match any"
// — same convention as `coverage::compute_coverage`.
//
// Schemas write `required-backlink` as either the forward
// link-type name (e.g. `satisfies` in `dev.yaml`) or the
// inverse name (e.g. `supported-by` in `safety-case.yaml`).
// Accept either so both conventions validate correctly —
// matching only `bl.link_type` would miss the inverse-name case.
// `alternate-backlinks` provides additional acceptable shapes
// for the same rule (e.g. a safety-goal supported via
// `supported-by` OR decomposed via `decomposed-by`).
if let Some(required_backlink) = &rule.required_backlink {
let has_backlink = graph.backlinks_to(id).iter().any(|bl| {
bl.link_type == *required_backlink
&& (rule.from_types.is_empty()
|| store
.get(&bl.source)
.is_some_and(|s| rule.from_types.contains(&s.artifact_type)))
});
if !has_backlink {
let backlinks = graph.backlinks_to(id);
let matches = |link_name: &str, from_types: &[String]| {
backlinks.iter().any(|bl| {
(bl.link_type == link_name || bl.inverse_type.as_deref() == Some(link_name))
&& (from_types.is_empty()
|| store
.get(&bl.source)
.is_some_and(|s| from_types.contains(&s.artifact_type)))
})
};
let primary = matches(required_backlink, &rule.from_types);
let alternate = rule
.alternate_backlinks
.iter()
.any(|alt| matches(&alt.link_type, &alt.from_types));
if !primary && !alternate {
diagnostics.push(Diagnostic {
source_file: None,
line: None,
Expand Down Expand Up @@ -2440,6 +2457,134 @@ then:
);
}

/// Issue #349: `required-backlink` written as the INVERSE link-type
/// name (e.g. `supported-by`, the convention used by
/// `schemas/safety-case.yaml`) was never matched against the stored
/// `Backlink.link_type` (the FORWARD name, e.g. `supports`). The
/// goal-has-support rule fired for every goal even when a solution
/// was correctly linked. Accept either spelling.
///
/// rivet: fixes REQ-004
#[test]
fn required_backlink_inverse_name_is_satisfied_by_forward_link() {
use crate::schema::LinkTypeDef;
let mut file = minimal_schema("test");
file.link_types.push(LinkTypeDef {
name: "supports".into(),
inverse: Some("supported-by".into()),
description: "Solution supports goal".into(),
source_types: vec!["safety-solution".into()],
target_types: vec!["safety-goal".into()],
});
file.traceability_rules = vec![TraceabilityRule {
name: "goal-has-support".into(),
description: "Every safety goal must be supported".into(),
source_type: "safety-goal".into(),
required_link: None,
// Inverse-name convention from safety-case.yaml.
required_backlink: Some("supported-by".into()),
target_types: vec![],
from_types: vec!["safety-solution".into()],
severity: Severity::Error,
alternate_backlinks: vec![],
}];
let schema = Schema::merge(&[file]);

let mut store = Store::new();
let mut goal = minimal_artifact("SG-1", "safety-goal");
goal.status = Some("approved".to_string());
store.insert(goal).unwrap();
let mut sol = minimal_artifact("SOL-1", "safety-solution");
sol.status = Some("approved".to_string());
sol.links = vec![Link {
link_type: "supports".to_string(),
target: "SG-1".to_string(),
external: None,
}];
store.insert(sol).unwrap();

let graph = LinkGraph::build(&store, &schema);
let diags = validate_structural(&store, &schema, &graph);
let rule_diags: Vec<_> = diags
.iter()
.filter(|d| d.rule == "goal-has-support")
.collect();
assert!(
rule_diags.is_empty(),
"SG-1 has a supported-by backlink (from SOL-1's forward `supports` link); \
the rule must not fire. Got diagnostics: {:?}",
rule_diags.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}

/// Issue #349 secondary: `validate.rs` never evaluated
/// `rule.alternate_backlinks`. A safety-goal satisfied only via
/// an alternate (e.g. `decomposed-by` instead of `supported-by`)
/// still erroneously fired the rule.
///
/// rivet: fixes REQ-004
#[test]
fn validate_honours_alternate_backlinks() {
use crate::schema::{AlternateBacklink, LinkTypeDef};
let mut file = minimal_schema("test");
file.link_types.push(LinkTypeDef {
name: "supports".into(),
inverse: Some("supported-by".into()),
description: "Solution supports goal".into(),
source_types: vec!["safety-solution".into()],
target_types: vec!["safety-goal".into()],
});
file.link_types.push(LinkTypeDef {
name: "decomposes".into(),
inverse: Some("decomposed-by".into()),
description: "Strategy decomposes a goal".into(),
source_types: vec!["safety-strategy".into()],
target_types: vec!["safety-goal".into()],
});
file.traceability_rules = vec![TraceabilityRule {
name: "goal-supported-or-decomposed".into(),
description: "Every goal supported OR decomposed".into(),
source_type: "safety-goal".into(),
required_link: None,
required_backlink: Some("supported-by".into()),
target_types: vec![],
from_types: vec!["safety-solution".into()],
alternate_backlinks: vec![AlternateBacklink {
link_type: "decomposed-by".into(),
from_types: vec!["safety-strategy".into()],
}],
severity: Severity::Error,
}];
let schema = Schema::merge(&[file]);

let mut store = Store::new();
let mut goal = minimal_artifact("SG-A", "safety-goal");
goal.status = Some("approved".to_string());
store.insert(goal).unwrap();
// Strategy decomposes SG-A. No solution at all.
let mut strat = minimal_artifact("STRAT-1", "safety-strategy");
strat.status = Some("approved".to_string());
strat.links = vec![Link {
link_type: "decomposes".to_string(),
target: "SG-A".to_string(),
external: None,
}];
store.insert(strat).unwrap();

let graph = LinkGraph::build(&store, &schema);
let diags = validate_structural(&store, &schema, &graph);
let rule_diags: Vec<_> = diags
.iter()
.filter(|d| d.rule == "goal-supported-or-decomposed")
.collect();
assert!(
rule_diags.is_empty(),
"SG-A is satisfied via the alternate `decomposed-by` backlink; \
the rule must not fire. Got: {:?}",
rule_diags.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}

// ── Mutation-pinning tests for link cardinality ────────────────────
//
// Each test pins one or more surviving mutants in
Expand Down
Loading