Skip to content

Commit 632eb8a

Browse files
authored
Merge pull request #661 from pulseengine/fix/issue-634-emission-exhaustiveness-guard
fix(mutate): compile-time exhaustiveness guard on render_artifact_yaml (#634)
2 parents f87e154 + 8df7895 commit 632eb8a

1 file changed

Lines changed: 203 additions & 28 deletions

File tree

rivet-core/src/mutate.rs

Lines changed: 203 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ use std::path::{Path, PathBuf};
5151

5252
use crate::error::Error;
5353
use crate::links::LinkGraph;
54-
use crate::model::{Artifact, Link};
54+
use crate::model::{Artifact, Link, Provenance};
5555
use crate::schema::Schema;
5656
use crate::store::Store;
5757

@@ -524,50 +524,70 @@ pub fn append_artifact_to_file(artifact: &Artifact, file_path: &Path) -> Result<
524524

525525
/// Render a single artifact as YAML suitable for appending under `artifacts:`.
526526
fn render_artifact_yaml(artifact: &Artifact) -> String {
527+
// Exhaustiveness guard (#634): destructure `Artifact` here so any new
528+
// struct field fails to compile — forcing an explicit decision about
529+
// whether the emitter needs to serialize it. Do NOT collapse this into
530+
// `..` — that defeats the whole point (the failure mode is REQ-203/#476:
531+
// a model field silently dropped on write). Fields intentionally not
532+
// emitted today are bound with a leading underscore and a comment naming
533+
// the write-path that would need to populate them.
534+
let Artifact {
535+
id,
536+
artifact_type,
537+
title,
538+
description,
539+
status,
540+
release,
541+
tags,
542+
links,
543+
fields,
544+
// No add/batch/MCP entry point populates variant overlays today (#255,
545+
// #634). If a write path starts writing them, emit here as a
546+
// `fields-per-variant:` block to avoid the REQ-203/#476 silent-drop.
547+
fields_per_variant: _fields_per_variant,
548+
provenance,
549+
// `source_file` is `#[serde(skip)]` — the on-disk location the artifact
550+
// was loaded from is intentionally never round-tripped.
551+
source_file: _source_file,
552+
} = artifact;
553+
527554
let mut lines = Vec::new();
528555

529556
use crate::yaml_edit::{yaml_quote_inline_scalar, yaml_render_scalar_value};
530557

531-
lines.push(format!(" - id: {}", artifact.id));
532-
lines.push(format!(" type: {}", artifact.artifact_type));
558+
lines.push(format!(" - id: {id}"));
559+
lines.push(format!(" type: {artifact_type}"));
533560
// Values are YAML-safe-quoted (colons, leading specials, etc.) and
534561
// multi-line values become block-literal scalars — reusing the same emitter
535562
// the modify/set path uses, so `add` never writes invalid YAML.
536-
lines.push(format!(
537-
" title: {}",
538-
yaml_quote_inline_scalar(&artifact.title)
539-
));
563+
lines.push(format!(" title: {}", yaml_quote_inline_scalar(title)));
540564

541-
if let Some(ref status) = artifact.status {
565+
if let Some(status) = status {
542566
lines.push(format!(" status: {}", yaml_quote_inline_scalar(status)));
543567
}
544568

545-
if let Some(ref release) = artifact.release {
569+
if let Some(release) = release {
546570
lines.push(format!(
547571
" release: {}",
548572
yaml_quote_inline_scalar(release)
549573
));
550574
}
551575

552-
if let Some(ref desc) = artifact.description {
576+
if let Some(desc) = description {
553577
lines.push(format!(
554578
" description: {}",
555579
yaml_render_scalar_value(desc, 4)
556580
));
557581
}
558582

559-
if !artifact.tags.is_empty() {
560-
let tag_list: Vec<String> = artifact
561-
.tags
562-
.iter()
563-
.map(|t| yaml_quote_inline_scalar(t))
564-
.collect();
583+
if !tags.is_empty() {
584+
let tag_list: Vec<String> = tags.iter().map(|t| yaml_quote_inline_scalar(t)).collect();
565585
lines.push(format!(" tags: [{}]", tag_list.join(", ")));
566586
}
567587

568-
if !artifact.fields.is_empty() {
588+
if !fields.is_empty() {
569589
lines.push(" fields:".to_string());
570-
for (key, value) in &artifact.fields {
590+
for (key, value) in fields {
571591
match value {
572592
serde_yaml::Value::String(s) => {
573593
lines.push(format!(" {key}: {}", yaml_render_scalar_value(s, 6)));
@@ -592,11 +612,24 @@ fn render_artifact_yaml(artifact: &Artifact) -> String {
592612
}
593613
}
594614

595-
if !artifact.links.is_empty() {
615+
if !links.is_empty() {
596616
lines.push(" links:".to_string());
597-
for link in &artifact.links {
598-
lines.push(format!(" - type: {}", link.link_type));
599-
lines.push(format!(" target: {}", link.target));
617+
for link in links {
618+
// Exhaustiveness guard (#634): same rationale as the outer
619+
// destructure. `Link.external` is only populated for `*-external`
620+
// link types (currently `derives-from-external`), and no add-path
621+
// writes those today. When one does, the flat `target: <id>` shape
622+
// below has to grow the mapping form documented in
623+
// `model.rs::Link` — else the structured cross-org payload
624+
// (org/contract/doc-id/last-synced/sha256/anchor) is silently
625+
// dropped on write.
626+
let Link {
627+
link_type,
628+
target,
629+
external: _external,
630+
} = link;
631+
lines.push(format!(" - type: {link_type}"));
632+
lines.push(format!(" target: {target}"));
600633
}
601634
}
602635

@@ -605,19 +638,32 @@ fn render_artifact_yaml(artifact: &Artifact) -> String {
605638
// silently dropped on write (the serializer is the single source of truth
606639
// for `add` output). Field order matches yaml_edit::set_provenance so the
607640
// add-path and stamp-path produce identical blocks.
608-
if let Some(ref prov) = artifact.provenance {
641+
if let Some(prov) = provenance {
642+
// Exhaustiveness guard (#634): same rationale as the outer
643+
// destructure. `Provenance.federation` is only populated by
644+
// `rivet supplier pull` (#288), which routes through a separate write
645+
// path today. When any add/stamp/MCP entry point learns to set it,
646+
// this block needs a `federation:` sub-block to avoid the silent-drop.
647+
let Provenance {
648+
created_by,
649+
model,
650+
session_id,
651+
timestamp,
652+
reviewed_by,
653+
federation: _federation,
654+
} = prov;
609655
lines.push(" provenance:".to_string());
610-
lines.push(format!(" created-by: {}", prov.created_by));
611-
if let Some(ref m) = prov.model {
656+
lines.push(format!(" created-by: {created_by}"));
657+
if let Some(m) = model {
612658
lines.push(format!(" model: {m}"));
613659
}
614-
if let Some(ref sid) = prov.session_id {
660+
if let Some(sid) = session_id {
615661
lines.push(format!(" session-id: {sid}"));
616662
}
617-
if let Some(ref ts) = prov.timestamp {
663+
if let Some(ts) = timestamp {
618664
lines.push(format!(" timestamp: {ts}"));
619665
}
620-
if let Some(ref rb) = prov.reviewed_by {
666+
if let Some(rb) = reviewed_by {
621667
lines.push(format!(" reviewed-by: {rb}"));
622668
}
623669
}
@@ -1123,4 +1169,133 @@ mod tests {
11231169
"unstamped add must not emit a provenance block, got:\n{body}"
11241170
);
11251171
}
1172+
1173+
// ── #634: exhaustiveness-guard companion tests ──────────────────────
1174+
//
1175+
// Three fields exist in the model today but are intentionally NOT
1176+
// written by `render_artifact_yaml` because no add/batch/MCP entry
1177+
// point populates them:
1178+
//
1179+
// - `Artifact.fields_per_variant` (variant overlays, #255)
1180+
// - `Link.external` (structured cross-org, #543/#288)
1181+
// - `Provenance.federation` (supplier-pull metadata, #288)
1182+
//
1183+
// The compile-time destructure guards at the top of the emitter
1184+
// ensure a *new* model field can't be added without touching the
1185+
// emitter. These runtime tests capture the *current* latent-drop
1186+
// shape for those three: they lock today's behavior so that when a
1187+
// write path starts populating any of them, this test file is the
1188+
// clear place the change surfaces — flip the assertion, emit the
1189+
// block. That's the second half of the guard.
1190+
1191+
#[test]
1192+
fn render_artifact_yaml_drops_fields_per_variant_when_populated_latent_gap_634() {
1193+
// See #634: if this test ever fails, a write path has learned to
1194+
// populate variant overlays — good — and the emitter must grow a
1195+
// `fields-per-variant:` block above. Do NOT delete the test;
1196+
// instead invert it (assert the block IS present) and update
1197+
// `render_artifact_yaml` to serialize it. The destructure guard
1198+
// above will hold that step in place for the next field.
1199+
let mut artifact = artifact_with_links("REQ-099", "requirement", &[]);
1200+
artifact.title = "T".to_string();
1201+
let mut overlay = std::collections::BTreeMap::new();
1202+
overlay.insert(
1203+
"priority".to_string(),
1204+
serde_yaml::Value::String("must".to_string()),
1205+
);
1206+
artifact
1207+
.fields_per_variant
1208+
.insert("automotive".to_string(), overlay);
1209+
1210+
let body = render_artifact_yaml(&artifact);
1211+
assert!(
1212+
!body.contains("fields-per-variant:"),
1213+
"no add-path populates fields_per_variant today (#634). If this now \
1214+
fires, flip the assertion and add serialization to \
1215+
render_artifact_yaml — the destructure guard above will hold you \
1216+
honest on the next field. Got:\n{body}"
1217+
);
1218+
}
1219+
1220+
#[test]
1221+
fn render_artifact_yaml_drops_link_external_when_populated_latent_gap_634() {
1222+
// See #634: same shape as the fields_per_variant test — when a
1223+
// write path starts populating `Link.external` (i.e. an add-path
1224+
// for `derives-from-external` links), the flat
1225+
// `target: <anchor-id>` shape here must grow the mapping form
1226+
// documented in `model.rs` (org/contract/doc-id/last-synced/
1227+
// sha256/anchor). Flip this assertion + emit the mapping.
1228+
use crate::model::ExternalLinkTarget;
1229+
let mut artifact = artifact_with_links("REQ-099", "requirement", &[]);
1230+
artifact.title = "T".to_string();
1231+
artifact.links = vec![Link {
1232+
link_type: "derives-from-external".to_string(),
1233+
target: "ANCHOR-ACME-001".to_string(),
1234+
external: Some(ExternalLinkTarget {
1235+
org: "acme-electronics".to_string(),
1236+
contract: Some("PO-4711".to_string()),
1237+
doc_id: Some("REQ-SW-022".to_string()),
1238+
last_synced: Some("2026-07-02".to_string()),
1239+
sha256: Some("deadbeef".to_string()),
1240+
anchor: "ANCHOR-ACME-001".to_string(),
1241+
}),
1242+
}];
1243+
1244+
let body = render_artifact_yaml(&artifact);
1245+
// Emitter today writes only the flat `target: <id>` — no `org:`,
1246+
// no `contract:`, no `sha256:`, no `anchor:` sub-keys.
1247+
assert!(
1248+
body.contains("target: ANCHOR-ACME-001"),
1249+
"flat target still emitted, got:\n{body}"
1250+
);
1251+
for latent_key in ["org:", "contract:", "doc-id:", "last-synced:", "sha256:"] {
1252+
assert!(
1253+
!body.contains(latent_key),
1254+
"no add-path populates Link.external today (#634). If \
1255+
`{latent_key}` now appears, flip this assertion and emit the \
1256+
mapping form. Got:\n{body}"
1257+
);
1258+
}
1259+
}
1260+
1261+
#[test]
1262+
fn render_artifact_yaml_drops_provenance_federation_when_populated_latent_gap_634() {
1263+
// See #634: `Provenance.federation` is set only by
1264+
// `rivet supplier pull` (#288), which routes through its own write
1265+
// path. When any add/stamp/MCP entry point learns to set it, this
1266+
// block must gain a `federation:` sub-block. Until then this
1267+
// regression test locks the current-behavior drop so the moment
1268+
// it changes is loud, not silent.
1269+
use crate::model::FederationProvenance;
1270+
let mut artifact = artifact_with_links("REQ-099", "requirement", &[]);
1271+
artifact.title = "T".to_string();
1272+
artifact.provenance = Some(Provenance {
1273+
created_by: "supplier-pull".to_string(),
1274+
model: None,
1275+
session_id: None,
1276+
timestamp: None,
1277+
reviewed_by: None,
1278+
federation: Some(FederationProvenance {
1279+
source_org: "acme-electronics".to_string(),
1280+
source_tool: "reqif-1.2".to_string(),
1281+
source_id: "REQ-SW-022".to_string(),
1282+
anchor: "ANCHOR-ACME-001".to_string(),
1283+
fetched_at: "2026-07-02T00:00:00Z".to_string(),
1284+
source_hash: "deadbeef".to_string(),
1285+
mapping_recipe: None,
1286+
}),
1287+
});
1288+
1289+
let body = render_artifact_yaml(&artifact);
1290+
assert!(
1291+
body.contains("created-by: supplier-pull"),
1292+
"existing provenance fields still emitted, got:\n{body}"
1293+
);
1294+
assert!(
1295+
!body.contains("federation:"),
1296+
"no add-path populates provenance.federation today (#634). If \
1297+
`federation:` now appears, flip this assertion and emit the \
1298+
sub-block. Got:\n{body}"
1299+
);
1300+
}
11261301
}

0 commit comments

Comments
 (0)