Skip to main content

mcd_core/
provenance.rs

1//! Package-level provenance metadata parsing and validation.
2
3use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6use time::{OffsetDateTime, format_description::well_known::Rfc3339};
7
8use crate::{
9    Manifest, McdPackage,
10    errors::{Diagnostic, McdError, Result},
11    manifest::{is_media_type, is_sha256, is_supported_external_uri, is_valid_id},
12    package::validate_internal_path,
13};
14
15/// Parsed package-level provenance metadata.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct ProvenanceMetadata {
19    /// Source documents or datasets used to produce the package.
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub sources: Vec<ProvenanceSource>,
22    /// People, organizations, software, or agents involved in provenance events.
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub actors: Vec<ProvenanceActor>,
25    /// Extraction, conversion, rendering, validation, or generation tools.
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub tools: Vec<ProvenanceTool>,
28    /// Generated package assets and the sources/tools that produced them.
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub generated_assets: Vec<GeneratedAsset>,
31    /// Ordered or unordered provenance activities.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub activities: Vec<ProvenanceActivity>,
34}
35
36impl ProvenanceMetadata {
37    /// Parse provenance metadata from a package entry.
38    pub fn from_package(package: &McdPackage, path: &str) -> Result<Self> {
39        let bytes = package.read(path).map_err(|_| {
40            McdError::from_diagnostic(
41                Diagnostic::error(
42                    "provenance.metadata.missing",
43                    format!("Declared provenance metadata file '{path}' is missing."),
44                )
45                .with_source(path.to_owned()),
46            )
47        })?;
48        serde_json::from_slice::<Self>(bytes).map_err(McdError::from)
49    }
50
51    /// Validate provenance metadata against the package and manifest.
52    pub fn validate(&self, manifest: &Manifest, package: &McdPackage, source: &str) -> Result<()> {
53        let indexes = ProvenanceIndexes::new(self, manifest, source)?;
54
55        for item in &self.sources {
56            item.validate(package, source)?;
57        }
58        for item in &self.actors {
59            item.validate(source)?;
60        }
61        for item in &self.tools {
62            item.validate(source)?;
63        }
64        for item in &self.generated_assets {
65            item.validate(package, &indexes, source)?;
66        }
67        for item in &self.activities {
68            item.validate(&indexes, source)?;
69        }
70
71        Ok(())
72    }
73}
74
75/// A source document, dataset, or external resource used to produce package content.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct ProvenanceSource {
79    /// Stable source id.
80    pub id: String,
81    /// Optional packaged source path.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub path: Option<String>,
84    /// Optional original or external URI.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub uri: Option<String>,
87    /// Optional media type.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub media_type: Option<String>,
90    /// Optional integrity hash.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub hash: Option<String>,
93    /// Optional expected size in bytes.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub size_bytes: Option<u64>,
96    /// Optional display title.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub title: Option<String>,
99    /// Optional source creation timestamp.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub created_at: Option<String>,
102    /// Optional source retrieval timestamp.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub retrieved_at: Option<String>,
105}
106
107impl ProvenanceSource {
108    fn validate(&self, package: &McdPackage, source: &str) -> Result<()> {
109        validate_id("provenance.source.id.invalid", &self.id, source)?;
110        if self.path.is_none() && self.uri.is_none() {
111            return Err(provenance_error(
112                "provenance.source.location.missing",
113                format!("Provenance source '{}' must declare path or uri.", self.id),
114                source,
115            ));
116        }
117        if let Some(path) = &self.path {
118            validate_package_path("provenance.source.path.invalid", path, source)?;
119            if !package.contains(path) {
120                return Err(provenance_error(
121                    "provenance.source.path.missing",
122                    format!(
123                        "Provenance source '{}' references missing package path '{}'.",
124                        self.id, path
125                    ),
126                    source,
127                ));
128            }
129        }
130        if let Some(uri) = &self.uri {
131            validate_uri("provenance.source.uri.invalid", uri, source)?;
132        }
133        validate_optional_media_type(
134            &self.media_type,
135            "provenance.source.media_type.invalid",
136            source,
137        )?;
138        validate_optional_hash(&self.hash, "provenance.source.hash.invalid", source)?;
139        validate_optional_text(&self.title, "provenance.source.title.empty", source)?;
140        validate_optional_timestamp(
141            &self.created_at,
142            "provenance.source.created_at.invalid",
143            source,
144        )?;
145        validate_optional_timestamp(
146            &self.retrieved_at,
147            "provenance.source.retrieved_at.invalid",
148            source,
149        )?;
150        Ok(())
151    }
152}
153
154/// Actor involved in a provenance activity.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ProvenanceActor {
158    /// Stable actor id.
159    pub id: String,
160    /// Actor type.
161    pub kind: ActorKind,
162    /// Optional actor display name.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub name: Option<String>,
165    /// Optional actor URI.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub uri: Option<String>,
168}
169
170impl ProvenanceActor {
171    fn validate(&self, source: &str) -> Result<()> {
172        validate_id("provenance.actor.id.invalid", &self.id, source)?;
173        validate_optional_text(&self.name, "provenance.actor.name.empty", source)?;
174        if let Some(uri) = &self.uri {
175            validate_uri("provenance.actor.uri.invalid", uri, source)?;
176        }
177        Ok(())
178    }
179}
180
181/// Supported provenance actor kinds.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum ActorKind {
185    /// A person.
186    Person,
187    /// An organization.
188    Organization,
189    /// A software process.
190    Software,
191    /// An autonomous or assisted agent.
192    Agent,
193}
194
195/// Tool used in extraction, conversion, validation, rendering, or generation.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct ProvenanceTool {
199    /// Stable tool id.
200    pub id: String,
201    /// Tool name.
202    pub name: String,
203    /// Optional version string.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub version: Option<String>,
206    /// Optional tool URI.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub uri: Option<String>,
209    /// Optional tool artifact hash.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub hash: Option<String>,
212}
213
214impl ProvenanceTool {
215    fn validate(&self, source: &str) -> Result<()> {
216        validate_id("provenance.tool.id.invalid", &self.id, source)?;
217        if self.name.trim().is_empty() {
218            return Err(provenance_error(
219                "provenance.tool.name.empty",
220                "Provenance tool name cannot be empty.",
221                source,
222            ));
223        }
224        validate_optional_text(&self.version, "provenance.tool.version.empty", source)?;
225        if let Some(uri) = &self.uri {
226            validate_uri("provenance.tool.uri.invalid", uri, source)?;
227        }
228        validate_optional_hash(&self.hash, "provenance.tool.hash.invalid", source)?;
229        Ok(())
230    }
231}
232
233/// Generated package asset with direct provenance links.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct GeneratedAsset {
237    /// Stable generated asset id.
238    pub id: String,
239    /// Generated package path.
240    pub path: String,
241    /// Optional media type.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub media_type: Option<String>,
244    /// Optional generated asset hash.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub hash: Option<String>,
247    /// Optional creation timestamp.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub created_at: Option<String>,
250    /// Source ids used to produce this asset.
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub source_refs: Vec<String>,
253    /// Tool ids used to produce this asset.
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub tool_refs: Vec<String>,
256    /// Actor ids responsible for this asset.
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub actor_refs: Vec<String>,
259}
260
261impl GeneratedAsset {
262    fn validate(
263        &self,
264        package: &McdPackage,
265        indexes: &ProvenanceIndexes,
266        source: &str,
267    ) -> Result<()> {
268        validate_id("provenance.generated_asset.id.invalid", &self.id, source)?;
269        validate_package_path(
270            "provenance.generated_asset.path.invalid",
271            &self.path,
272            source,
273        )?;
274        if !package.contains(&self.path) {
275            return Err(provenance_error(
276                "provenance.generated_asset.path.missing",
277                format!(
278                    "Generated asset '{}' references missing package path '{}'.",
279                    self.id, self.path
280                ),
281                source,
282            ));
283        }
284        validate_optional_media_type(
285            &self.media_type,
286            "provenance.generated_asset.media_type.invalid",
287            source,
288        )?;
289        validate_optional_hash(
290            &self.hash,
291            "provenance.generated_asset.hash.invalid",
292            source,
293        )?;
294        validate_optional_timestamp(
295            &self.created_at,
296            "provenance.generated_asset.created_at.invalid",
297            source,
298        )?;
299        validate_refs(
300            "provenance.generated_asset.source_ref.unresolved",
301            &self.source_refs,
302            &indexes.sources,
303            "source",
304            source,
305        )?;
306        validate_refs(
307            "provenance.generated_asset.tool_ref.unresolved",
308            &self.tool_refs,
309            &indexes.tools,
310            "tool",
311            source,
312        )?;
313        validate_refs(
314            "provenance.generated_asset.actor_ref.unresolved",
315            &self.actor_refs,
316            &indexes.actors,
317            "actor",
318            source,
319        )?;
320        Ok(())
321    }
322}
323
324/// Provenance activity linking actors, tools, inputs, and outputs.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct ProvenanceActivity {
328    /// Stable activity id.
329    pub id: String,
330    /// Activity kind.
331    pub kind: ActivityKind,
332    /// Optional activity start timestamp.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub started_at: Option<String>,
335    /// Optional activity end timestamp.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub ended_at: Option<String>,
338    /// Actor ids involved in the activity.
339    #[serde(default, skip_serializing_if = "Vec::is_empty")]
340    pub actor_refs: Vec<String>,
341    /// Tool ids used by the activity.
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub tool_refs: Vec<String>,
344    /// Source ids used by the activity.
345    #[serde(default, skip_serializing_if = "Vec::is_empty")]
346    pub source_refs: Vec<String>,
347    /// Generic input references for package paths or future object kinds.
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub input_refs: Vec<String>,
350    /// Generic output references for package paths or future object kinds.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub output_refs: Vec<String>,
353    /// Optional human-readable description.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub description: Option<String>,
356}
357
358impl ProvenanceActivity {
359    fn validate(&self, indexes: &ProvenanceIndexes, source: &str) -> Result<()> {
360        validate_id("provenance.activity.id.invalid", &self.id, source)?;
361        let started_at = validate_optional_timestamp(
362            &self.started_at,
363            "provenance.activity.started_at.invalid",
364            source,
365        )?;
366        let ended_at = validate_optional_timestamp(
367            &self.ended_at,
368            "provenance.activity.ended_at.invalid",
369            source,
370        )?;
371        if let (Some(started_at), Some(ended_at)) = (started_at, ended_at)
372            && ended_at < started_at
373        {
374            return Err(provenance_error(
375                "provenance.activity.time_order.invalid",
376                format!("Provenance activity '{}' ends before it starts.", self.id),
377                source,
378            ));
379        }
380        validate_refs(
381            "provenance.activity.actor_ref.unresolved",
382            &self.actor_refs,
383            &indexes.actors,
384            "actor",
385            source,
386        )?;
387        validate_refs(
388            "provenance.activity.tool_ref.unresolved",
389            &self.tool_refs,
390            &indexes.tools,
391            "tool",
392            source,
393        )?;
394        validate_refs(
395            "provenance.activity.source_ref.unresolved",
396            &self.source_refs,
397            &indexes.sources,
398            "source",
399            source,
400        )?;
401        validate_generic_refs(
402            "provenance.activity.input_ref.invalid",
403            &self.input_refs,
404            indexes,
405            source,
406        )?;
407        validate_generic_refs(
408            "provenance.activity.output_ref.invalid",
409            &self.output_refs,
410            indexes,
411            source,
412        )?;
413        validate_optional_text(
414            &self.description,
415            "provenance.activity.description.empty",
416            source,
417        )?;
418        Ok(())
419    }
420}
421
422/// Supported provenance activity kinds.
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(rename_all = "snake_case")]
425pub enum ActivityKind {
426    /// Human or system authored source content.
427    Authored,
428    /// Source material was imported.
429    Imported,
430    /// Content was extracted from source material.
431    Extracted,
432    /// Content was transformed.
433    Transformed,
434    /// New content or assets were generated.
435    Generated,
436    /// Package content was validated.
437    Validated,
438    /// Package content was rendered.
439    Rendered,
440    /// Package content was reviewed.
441    Reviewed,
442}
443
444/// Load and validate manifest-declared provenance metadata.
445pub fn load_manifest_provenance(
446    package: &McdPackage,
447    manifest: &Manifest,
448) -> Result<Option<ProvenanceMetadata>> {
449    let Some(path) = &manifest.provenance else {
450        return Ok(None);
451    };
452    let metadata = ProvenanceMetadata::from_package(package, path)?;
453    metadata.validate(manifest, package, path)?;
454    Ok(Some(metadata))
455}
456
457struct ProvenanceIndexes {
458    sources: HashSet<String>,
459    actors: HashSet<String>,
460    tools: HashSet<String>,
461    generated_assets: HashSet<String>,
462    tables: HashSet<String>,
463    images: HashSet<String>,
464    annotations: HashSet<String>,
465    external_data: HashSet<String>,
466    assets: HashSet<String>,
467}
468
469impl ProvenanceIndexes {
470    fn new(metadata: &ProvenanceMetadata, manifest: &Manifest, source: &str) -> Result<Self> {
471        let sources = collect_ids(
472            "provenance.source.id.duplicate",
473            metadata.sources.iter().map(|item| item.id.as_str()),
474            "source",
475            source,
476        )?;
477        let actors = collect_ids(
478            "provenance.actor.id.duplicate",
479            metadata.actors.iter().map(|item| item.id.as_str()),
480            "actor",
481            source,
482        )?;
483        let tools = collect_ids(
484            "provenance.tool.id.duplicate",
485            metadata.tools.iter().map(|item| item.id.as_str()),
486            "tool",
487            source,
488        )?;
489        let generated_assets = collect_ids(
490            "provenance.generated_asset.id.duplicate",
491            metadata
492                .generated_assets
493                .iter()
494                .map(|item| item.id.as_str()),
495            "generated asset",
496            source,
497        )?;
498        collect_ids(
499            "provenance.activity.id.duplicate",
500            metadata.activities.iter().map(|item| item.id.as_str()),
501            "activity",
502            source,
503        )?;
504
505        Ok(Self {
506            sources,
507            actors,
508            tools,
509            generated_assets,
510            tables: manifest.tables.iter().map(|item| item.id.clone()).collect(),
511            images: manifest.images.iter().map(|item| item.id.clone()).collect(),
512            annotations: manifest
513                .annotations
514                .iter()
515                .map(|item| item.id.clone())
516                .collect(),
517            external_data: manifest
518                .external_data
519                .iter()
520                .map(|item| item.id.clone())
521                .collect(),
522            assets: manifest
523                .assets
524                .iter()
525                .filter_map(|item| item.id.clone())
526                .collect(),
527        })
528    }
529}
530
531fn collect_ids<'a>(
532    code: &'static str,
533    ids: impl Iterator<Item = &'a str>,
534    label: &str,
535    source: &str,
536) -> Result<HashSet<String>> {
537    let mut seen = HashSet::new();
538    for id in ids {
539        if !seen.insert(id.to_owned()) {
540            return Err(provenance_error(
541                code,
542                format!("Duplicate provenance {label} id '{id}'."),
543                source,
544            ));
545        }
546    }
547    Ok(seen)
548}
549
550fn validate_id(code: &'static str, id: &str, source: &str) -> Result<()> {
551    if !is_valid_id(id) {
552        return Err(provenance_error(
553            code,
554            format!("Invalid provenance id '{id}'."),
555            source,
556        ));
557    }
558    Ok(())
559}
560
561fn validate_package_path(code: &'static str, path: &str, source: &str) -> Result<()> {
562    validate_internal_path(path).map(|_| ()).map_err(|_| {
563        provenance_error(
564            code,
565            format!("Invalid provenance package path '{path}'."),
566            source,
567        )
568    })
569}
570
571fn validate_uri(code: &'static str, uri: &str, source: &str) -> Result<()> {
572    if !is_supported_external_uri(uri) {
573        return Err(provenance_error(
574            code,
575            format!("Invalid provenance URI '{uri}'."),
576            source,
577        ));
578    }
579    Ok(())
580}
581
582fn validate_optional_media_type(
583    value: &Option<String>,
584    code: &'static str,
585    source: &str,
586) -> Result<()> {
587    if let Some(value) = value
588        && !is_media_type(value)
589    {
590        return Err(provenance_error(
591            code,
592            format!("Invalid provenance media type '{value}'."),
593            source,
594        ));
595    }
596    Ok(())
597}
598
599fn validate_optional_hash(value: &Option<String>, code: &'static str, source: &str) -> Result<()> {
600    if let Some(value) = value
601        && !is_sha256(value)
602    {
603        return Err(provenance_error(
604            code,
605            "Provenance hash must use sha256:<64 lowercase hex characters>.",
606            source,
607        ));
608    }
609    Ok(())
610}
611
612fn validate_optional_text(value: &Option<String>, code: &'static str, source: &str) -> Result<()> {
613    if value
614        .as_deref()
615        .is_some_and(|value| value.trim().is_empty())
616    {
617        return Err(provenance_error(
618            code,
619            "Provenance text fields cannot be empty.",
620            source,
621        ));
622    }
623    Ok(())
624}
625
626fn validate_optional_timestamp(
627    value: &Option<String>,
628    code: &'static str,
629    source: &str,
630) -> Result<Option<OffsetDateTime>> {
631    let Some(value) = value else {
632        return Ok(None);
633    };
634    OffsetDateTime::parse(value, &Rfc3339)
635        .map(Some)
636        .map_err(|_| {
637            provenance_error(
638                code,
639                format!("Provenance timestamp '{value}' must be RFC 3339 date-time."),
640                source,
641            )
642        })
643}
644
645fn validate_refs(
646    code: &'static str,
647    refs: &[String],
648    allowed: &HashSet<String>,
649    label: &str,
650    source: &str,
651) -> Result<()> {
652    let mut seen = HashSet::new();
653    for reference in refs {
654        if reference.trim().is_empty() {
655            return Err(provenance_error(
656                code,
657                format!("Provenance {label} reference cannot be empty."),
658                source,
659            ));
660        }
661        if !seen.insert(reference) {
662            return Err(provenance_error(
663                code,
664                format!("Duplicate provenance {label} reference '{reference}'."),
665                source,
666            ));
667        }
668        if !allowed.contains(reference) {
669            return Err(provenance_error(
670                code,
671                format!("Provenance {label} reference '{reference}' is unresolved."),
672                source,
673            ));
674        }
675    }
676    Ok(())
677}
678
679fn validate_generic_refs(
680    code: &'static str,
681    refs: &[String],
682    indexes: &ProvenanceIndexes,
683    source: &str,
684) -> Result<()> {
685    let mut seen = HashSet::new();
686    for reference in refs {
687        if reference.trim().is_empty() {
688            return Err(provenance_error(
689                code,
690                "Provenance activity references cannot be empty.",
691                source,
692            ));
693        }
694        if !seen.insert(reference) {
695            return Err(provenance_error(
696                code,
697                format!("Duplicate provenance activity reference '{reference}'."),
698                source,
699            ));
700        }
701        if let Some((kind, id)) = reference.split_once(':') {
702            validate_typed_ref(code, kind, id, indexes, source)?;
703        }
704    }
705    Ok(())
706}
707
708fn validate_typed_ref(
709    code: &'static str,
710    kind: &str,
711    id: &str,
712    indexes: &ProvenanceIndexes,
713    source: &str,
714) -> Result<()> {
715    if id.trim().is_empty() {
716        return Err(provenance_error(
717            code,
718            "Typed provenance references must include an id.",
719            source,
720        ));
721    }
722
723    let resolved = match kind {
724        "source" => indexes.sources.contains(id),
725        "actor" => indexes.actors.contains(id),
726        "tool" => indexes.tools.contains(id),
727        "generatedAsset" | "generated_asset" => indexes.generated_assets.contains(id),
728        "table" => indexes.tables.contains(id),
729        "image" => indexes.images.contains(id),
730        "annotation" => indexes.annotations.contains(id),
731        "externalData" | "external_data" => indexes.external_data.contains(id),
732        "asset" => indexes.assets.contains(id),
733        "path" => validate_internal_path(id).is_ok(),
734        _ => true,
735    };
736
737    if !resolved {
738        return Err(provenance_error(
739            code,
740            format!("Typed provenance reference '{kind}:{id}' is unresolved."),
741            source,
742        ));
743    }
744    Ok(())
745}
746
747fn provenance_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
748    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use std::io::{Cursor, Write};
755    use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
756
757    #[test]
758    fn loads_and_validates_provenance_metadata() {
759        let package = package_with_provenance(valid_provenance());
760        let manifest = package.manifest().expect("manifest");
761        let provenance = load_manifest_provenance(&package, &manifest)
762            .expect("provenance loads")
763            .expect("provenance is present");
764
765        assert_eq!(provenance.sources[0].id, "source-pdf");
766        assert_eq!(provenance.generated_assets[0].path, "content/main.md");
767    }
768
769    #[test]
770    fn rejects_unresolved_provenance_source_reference() {
771        let package = package_with_provenance(
772            r#"{
773                "generatedAssets":[{
774                    "id":"main-md",
775                    "path":"content/main.md",
776                    "sourceRefs":["missing-source"]
777                }]
778            }"#,
779        );
780        let manifest = package.manifest().expect("manifest");
781        let err = load_manifest_provenance(&package, &manifest)
782            .expect_err("unresolved source reference should fail");
783
784        assert_eq!(
785            err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
786            Some("provenance.generated_asset.source_ref.unresolved")
787        );
788    }
789
790    #[test]
791    fn rejects_invalid_provenance_timestamp() {
792        let package = package_with_provenance(
793            r#"{
794                "sources":[{
795                    "id":"source-pdf",
796                    "path":"assets/source.pdf",
797                    "createdAt":"2026-05-26"
798                }]
799            }"#,
800        );
801        let manifest = package.manifest().expect("manifest");
802        let err = load_manifest_provenance(&package, &manifest)
803            .expect_err("date without time zone should fail");
804
805        assert_eq!(
806            err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
807            Some("provenance.source.created_at.invalid")
808        );
809    }
810
811    fn valid_provenance() -> &'static str {
812        r#"{
813            "sources":[{
814                "id":"source-pdf",
815                "path":"assets/source.pdf",
816                "mediaType":"application/pdf",
817                "hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000",
818                "createdAt":"2026-05-26T10:00:00Z"
819            }],
820            "actors":[{"id":"agent-1","kind":"agent","name":"Extraction agent"}],
821            "tools":[{"id":"extractor","name":"mcd-pdf-extract","version":"0.1.0"}],
822            "generatedAssets":[{
823                "id":"main-md",
824                "path":"content/main.md",
825                "mediaType":"text/markdown",
826                "createdAt":"2026-05-26T10:01:00Z",
827                "sourceRefs":["source-pdf"],
828                "toolRefs":["extractor"],
829                "actorRefs":["agent-1"]
830            }],
831            "activities":[{
832                "id":"extract-1",
833                "kind":"extracted",
834                "startedAt":"2026-05-26T10:00:00Z",
835                "endedAt":"2026-05-26T10:01:00Z",
836                "sourceRefs":["source-pdf"],
837                "toolRefs":["extractor"],
838                "actorRefs":["agent-1"],
839                "inputRefs":["source:source-pdf"],
840                "outputRefs":["generatedAsset:main-md", "path:content/main.md"]
841            }]
842        }"#
843    }
844
845    fn package_with_provenance(provenance: &str) -> McdPackage {
846        McdPackage::from_bytes(&zip_bytes(&[
847            ("mimetype", crate::package::MCD_MIMETYPE),
848            (
849                "manifest.json",
850                r#"{
851                    "format":"MCD",
852                    "version":"0.1",
853                    "profile":"MCD-Core",
854                    "entrypoint":"content/main.md",
855                    "assets":[{"id":"source-pdf","path":"assets/source.pdf"}],
856                    "provenance":"provenance/provenance.json"
857                }"#,
858            ),
859            ("content/main.md", "# Source\n"),
860            ("assets/source.pdf", "%PDF-1.7\n"),
861            ("provenance/provenance.json", provenance),
862        ]))
863        .expect("package opens")
864    }
865
866    fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
867        let cursor = Cursor::new(Vec::new());
868        let mut writer = ZipWriter::new(cursor);
869        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
870
871        for (path, content) in entries {
872            writer.start_file(*path, options).expect("start file");
873            writer.write_all(content.as_bytes()).expect("write file");
874        }
875
876        writer.finish().expect("finish zip").into_inner()
877    }
878}