@@ -51,7 +51,7 @@ use std::path::{Path, PathBuf};
5151
5252use crate :: error:: Error ;
5353use crate :: links:: LinkGraph ;
54- use crate :: model:: { Artifact , Link } ;
54+ use crate :: model:: { Artifact , Link , Provenance } ;
5555use crate :: schema:: Schema ;
5656use 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:`.
526526fn 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