Skip to content

Commit cdeb7f8

Browse files
authored
Merge pull request #650 from pulseengine/fix/issue-648-bidirectional-unauthorable-inverse
fix(check): skip bidirectional oracle when inverse is not authorable (#648)
2 parents f2329f0 + 35bd2ad commit cdeb7f8

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55

66
## [Unreleased]
77

8+
### Changed / Fixed
9+
- **`rivet check bidirectional` no longer demands non-authorable inverses**
10+
(#648). When a schema declares `link-types.X.inverse: Y` but does *not*
11+
declare `Y` as its own `link-types:` entry, authoring a `Y` link would
12+
make `rivet validate` FAIL with `unknown-link-type`. The oracle now
13+
treats such forward links as bidirectionally-implicit — the schema is
14+
unidirectionally declared and demanding a materialized inverse would
15+
make the oracle unsatisfiable in tandem with `validate`. Fixes the
16+
aspice-schema case (`satisfies`, `verifies`, `derives-from`) where one
17+
oracle's green was another's red. Adds `Schema::is_authorable_link_type`.
18+
819
## [0.23.0] - 2026-07-01
920

1021
Theme: **Traceability evidence** — make the requirement → verification → test

rivet-cli/src/check/bidirectional.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44
//! `type` has an `inverse:`, verify that `B -(inverse)-> A` is present in
55
//! the store. If any forward link lacks its inverse, the oracle fires.
66
//!
7+
//! ### Unidirectionally-declared inverses (issue #648)
8+
//!
9+
//! When a schema declares `link-types.X.inverse: Y` but does *not* declare
10+
//! `Y` as its own authorable `link-types:` entry, the reverse edge cannot
11+
//! be materialized on the target: authoring a `Y` link makes `rivet
12+
//! validate` FAIL with `unknown-link-type`. In that case the schema author
13+
//! is expressing "the inverse is a reading of the same edge, not a
14+
//! separately-authored link" — and this oracle honors that by skipping the
15+
//! forward link (its bidirectionality is implicit in the schema's
16+
//! declaration, no target-side materialization required). This keeps the
17+
//! oracle satisfiable simultaneously with `rivet validate` PASS. Projects
18+
//! that want dual-materialized inverses declare both directions in
19+
//! `link-types:` (see `allocated-to`/`allocated-from`), and the oracle
20+
//! then verifies both sides as before.
21+
//!
722
//! Exit codes:
823
//! * 0 — every inverse-bearing forward link has its inverse on the target.
924
//! * 1 — one or more inverses missing; violations printed (and emitted as
@@ -60,6 +75,16 @@ pub fn compute(store: &Store, schema: &Schema, graph: &LinkGraph) -> Report {
6075
let Some(expected_inverse) = schema.inverse_of(&link.link_type) else {
6176
continue;
6277
};
78+
// #648: if the schema declares `inverse: Y` but Y is not itself
79+
// an authorable `link-types:` entry, the target cannot carry a
80+
// Y-link without `rivet validate` rejecting it as
81+
// `unknown-link-type`. Treat such forward links as
82+
// bidirectionally-implicit — the schema is unidirectionally
83+
// declared and demanding a materialized inverse would make the
84+
// oracle unsatisfiable in tandem with `validate`.
85+
if !schema.is_authorable_link_type(expected_inverse) {
86+
continue;
87+
}
6388
// Skip broken links — those are a separate validator concern.
6489
// The target must exist for us to check its backlinks.
6590
if !store.contains(&link.target) {

rivet-cli/tests/check_oracles.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ fn rivet_bin() -> PathBuf {
3939

4040
/// Minimal schema: one artifact type, one link type with inverse, plus
4141
/// whatever else is needed for validation to accept a clean project.
42+
///
43+
/// Both `satisfies` and `satisfied-by` are declared as authorable link
44+
/// types so that `rivet check bidirectional` fires when the inverse is
45+
/// missing (per #648, the oracle skips forward links whose declared
46+
/// inverse is not itself authorable). Test 1 (`_passes_when_every_forward_link_has_inverse`)
47+
/// and test 2 (`_fires_when_inverse_missing`) both rely on this schema
48+
/// staying bidirectionally-authorable.
4249
const MINIMAL_SCHEMA: &str = r#"schema:
4350
name: oracle-test
4451
version: "0.1.0"
@@ -57,6 +64,12 @@ link-types:
5764
description: Source satisfies target
5865
source-types: [design-decision]
5966
target-types: [requirement]
67+
68+
- name: satisfied-by
69+
inverse: satisfies
70+
description: Target is satisfied by source
71+
source-types: [requirement]
72+
target-types: [design-decision]
6073
"#;
6174

6275
const MINIMAL_RIVET_YAML: &str = r#"project:
@@ -198,6 +211,126 @@ fn bidirectional_fires_when_inverse_missing() {
198211
assert_eq!(viols[0]["expected_inverse"], "satisfied-by");
199212
}
200213

214+
/// Schema that declares `satisfies` with an inverse name but does NOT
215+
/// declare that inverse (`satisfied-by`) as its own authorable link type.
216+
/// This is the shape called out in #648 (aspice embeds `satisfies`,
217+
/// `verifies`, `derives-from` this way): the inverse cannot be
218+
/// materialized without failing `rivet validate` — so the bidirectional
219+
/// oracle must treat the forward link as sufficient.
220+
const UNIDIRECTIONAL_SCHEMA: &str = r#"schema:
221+
name: oracle-test-uni
222+
version: "0.1.0"
223+
description: Schema whose `satisfies` inverse is not itself authorable.
224+
225+
artifact-types:
226+
- name: requirement
227+
description: A requirement
228+
229+
- name: design-decision
230+
description: A design decision
231+
232+
link-types:
233+
- name: satisfies
234+
inverse: satisfied-by
235+
description: Source satisfies target
236+
source-types: [design-decision]
237+
target-types: [requirement]
238+
"#;
239+
240+
const UNIDIRECTIONAL_RIVET_YAML: &str = r#"project:
241+
name: oracle-test-uni
242+
version: "0.1.0"
243+
schemas:
244+
- oracle-test-uni
245+
sources:
246+
- path: artifacts
247+
format: generic-yaml
248+
"#;
249+
250+
fn seed_unidirectional_project(dir: &Path) {
251+
std::fs::create_dir_all(dir.join("schemas")).unwrap();
252+
std::fs::create_dir_all(dir.join("artifacts")).unwrap();
253+
std::fs::write(dir.join("rivet.yaml"), UNIDIRECTIONAL_RIVET_YAML).unwrap();
254+
std::fs::write(
255+
dir.join("schemas").join("oracle-test-uni.yaml"),
256+
UNIDIRECTIONAL_SCHEMA,
257+
)
258+
.unwrap();
259+
}
260+
261+
/// #648 kill criterion: on a schema whose `satisfies.inverse` is not
262+
/// declared as an authorable link type, a project that only authors
263+
/// forward `satisfies` links must reach `bidirectional: OK` *and* pass
264+
/// `rivet validate` at the same time. Before this fix, the oracle
265+
/// demanded a `satisfied-by` inverse on every REQ, but validate rejected
266+
/// that inverse as `unknown-link-type` — one oracle's green was another
267+
/// oracle's red.
268+
#[test]
269+
fn bidirectional_and_validate_both_pass_when_inverse_is_not_authorable() {
270+
let tmp = tempfile::tempdir().unwrap();
271+
let dir = tmp.path();
272+
seed_unidirectional_project(dir);
273+
274+
// DD-001 authors the forward `satisfies` link. REQ-001 authors
275+
// nothing — the schema forbids `satisfied-by`, and the fix means the
276+
// bidirectional oracle no longer demands it.
277+
write_artifact(
278+
dir,
279+
"req.yaml",
280+
r#"artifacts:
281+
- id: REQ-001
282+
type: requirement
283+
title: a requirement
284+
status: draft
285+
"#,
286+
);
287+
write_artifact(
288+
dir,
289+
"dd.yaml",
290+
r#"artifacts:
291+
- id: DD-001
292+
type: design-decision
293+
title: a design decision
294+
status: draft
295+
links:
296+
- type: satisfies
297+
target: REQ-001
298+
"#,
299+
);
300+
301+
// Half 1: bidirectional oracle green.
302+
let bi = run_rivet(dir, &["check", "bidirectional", "--format", "json"]);
303+
let bi_stdout = String::from_utf8_lossy(&bi.stdout);
304+
let bi_stderr = String::from_utf8_lossy(&bi.stderr);
305+
assert!(
306+
bi.status.success(),
307+
"expected bidirectional OK; stdout={bi_stdout}; stderr={bi_stderr}"
308+
);
309+
let bi_v: serde_json::Value =
310+
serde_json::from_str(&bi_stdout).expect("bidirectional stdout must be valid JSON");
311+
assert_eq!(bi_v["oracle"], "bidirectional");
312+
assert_eq!(
313+
bi_v["violations"].as_array().unwrap().len(),
314+
0,
315+
"expected zero bidirectional violations, got: {bi_stdout}"
316+
);
317+
318+
// Half 2: validate green — no unknown-link-type error for the inverse,
319+
// because we never authored it.
320+
let val = run_rivet(dir, &["validate", "--format", "json"]);
321+
let val_stdout = String::from_utf8_lossy(&val.stdout);
322+
let val_stderr = String::from_utf8_lossy(&val.stderr);
323+
assert!(
324+
val.status.success(),
325+
"expected validate PASS; stdout={val_stdout}; stderr={val_stderr}"
326+
);
327+
// No `unknown-link-type` diagnostic anywhere in the validate output.
328+
assert!(
329+
!val_stdout.contains("unknown-link-type") && !val_stderr.contains("unknown-link-type"),
330+
"validate should not emit unknown-link-type; stdout={val_stdout}; stderr={val_stderr}"
331+
);
332+
}
333+
201334
// ── review-signoff oracle ──────────────────────────────────────────────
202335

203336
#[test]

rivet-core/src/schema.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,20 @@ impl Schema {
13541354
pub fn inverse_of(&self, link_type: &str) -> Option<&str> {
13551355
self.inverse_map.get(link_type).map(|s| s.as_str())
13561356
}
1357+
1358+
/// True if `link_type` is declared as its own `link-types:` entry — i.e.
1359+
/// a project may materialize links of that type. A name that only appears
1360+
/// as an `inverse:` on some other link type (with no `LinkTypeDef` of its
1361+
/// own) is not authorable: `rivet validate` will reject artifacts that
1362+
/// carry such links with `unknown-link-type`. Callers that reason about
1363+
/// what a project can *write* (as opposed to what edges the schema
1364+
/// implies) use this predicate. Notably the `bidirectional` oracle uses
1365+
/// it to avoid demanding an inverse the schema forbids authoring — see
1366+
/// #648.
1367+
#[inline]
1368+
pub fn is_authorable_link_type(&self, link_type: &str) -> bool {
1369+
self.link_types.contains_key(link_type)
1370+
}
13571371
}
13581372

13591373
#[cfg(test)]

0 commit comments

Comments
 (0)