Skip to main content

postproject_core/
provenance.rs

1//! Extensible production-provenance vocabulary and participant identities.
2
3use std::collections::BTreeSet;
4
5use crate::{
6    ActivityId, Error, ErrorKind, ExternalIdentifier, RepresentationId, Result, Timestamp,
7    uri::normalize_uri,
8};
9
10/// Maximum encoded length of an activity-kind identifier.
11pub const MAX_ACTIVITY_KIND_BYTES: usize = 128;
12/// Maximum encoded length of an activity-edge role identifier.
13pub const MAX_ACTIVITY_ROLE_BYTES: usize = 128;
14/// Maximum UTF-8 byte length of a tool or agent display name.
15pub const MAX_PROVENANCE_NAME_BYTES: usize = 256;
16/// Maximum UTF-8 byte length of an optional tool version.
17pub const MAX_TOOL_VERSION_BYTES: usize = 128;
18/// Maximum UTF-8 byte length of an optional tool or vendor URI.
19pub const MAX_PROVENANCE_URI_BYTES: usize = 4_096;
20/// Maximum number of input or output edges on one activity.
21pub const MAX_ACTIVITY_EDGES: usize = 100_000;
22
23/// A namespaced, open-world production activity kind.
24#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
25pub struct ActivityKind(String);
26
27impl ActivityKind {
28    /// Creates a kind such as `org.postproject:transcode`.
29    ///
30    /// # Errors
31    ///
32    /// Returns [`ErrorKind::InvalidArgument`] when the value is not a bounded
33    /// namespaced ASCII identifier.
34    pub fn new(value: impl Into<String>) -> Result<Self> {
35        validate_namespaced_identifier("activity kind", value.into(), MAX_ACTIVITY_KIND_BYTES)
36            .map(Self)
37    }
38
39    /// Returns the exact kind identifier.
40    #[must_use]
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44}
45
46/// A namespaced, open-world role for an activity input or output.
47#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
48pub struct ActivityRole(String);
49
50impl ActivityRole {
51    /// Creates a role such as `org.postproject:input.primary-video`.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`ErrorKind::InvalidArgument`] when the value is not a bounded
56    /// namespaced ASCII identifier.
57    pub fn new(value: impl Into<String>) -> Result<Self> {
58        validate_namespaced_identifier("activity role", value.into(), MAX_ACTIVITY_ROLE_BYTES)
59            .map(Self)
60    }
61
62    /// Returns the exact role identifier.
63    #[must_use]
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67}
68
69/// The software or service that performed a production activity.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct ToolIdentity {
72    name: String,
73    version: Option<String>,
74    uri: Option<String>,
75}
76
77impl ToolIdentity {
78    /// Creates a bounded tool identity with an optional absolute URI.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`ErrorKind::InvalidArgument`] for empty or oversized text, NUL
83    /// bytes, or an invalid/relative URI.
84    pub fn new(
85        name: impl Into<String>,
86        version: Option<String>,
87        uri: Option<String>,
88    ) -> Result<Self> {
89        let name = name.into();
90        validate_text("tool name", &name, MAX_PROVENANCE_NAME_BYTES)?;
91        if let Some(version) = version.as_deref() {
92            validate_text("tool version", version, MAX_TOOL_VERSION_BYTES)?;
93        }
94        let uri = uri.map(|value| normalize_uri(value, "tool")).transpose()?;
95        if uri
96            .as_ref()
97            .is_some_and(|value| value.len() > MAX_PROVENANCE_URI_BYTES)
98        {
99            return Err(Error::new(
100                ErrorKind::InvalidArgument,
101                format!("tool URI must not exceed {MAX_PROVENANCE_URI_BYTES} UTF-8 bytes"),
102            ));
103        }
104        Ok(Self { name, version, uri })
105    }
106
107    /// Returns the user-facing tool name.
108    #[must_use]
109    pub fn name(&self) -> &str {
110        &self.name
111    }
112
113    /// Returns the optional exact version or build string.
114    #[must_use]
115    pub fn version(&self) -> Option<&str> {
116        self.version.as_deref()
117    }
118
119    /// Returns the optional canonical absolute tool/vendor URI.
120    #[must_use]
121    pub fn uri(&self) -> Option<&str> {
122        self.uri.as_deref()
123    }
124}
125
126/// A person, organization, application instance, or service responsible for an activity.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct AgentIdentity {
129    name: Option<String>,
130    identifier: Option<ExternalIdentifier>,
131}
132
133/// One representation consumed by an activity.
134#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
135pub struct ActivityInput {
136    representation_id: RepresentationId,
137    role: Option<ActivityRole>,
138}
139
140impl ActivityInput {
141    /// Creates an activity input with an optional extensible role.
142    #[must_use]
143    pub const fn new(representation_id: RepresentationId, role: Option<ActivityRole>) -> Self {
144        Self {
145            representation_id,
146            role,
147        }
148    }
149
150    /// Returns the consumed representation.
151    #[must_use]
152    pub const fn representation_id(&self) -> RepresentationId {
153        self.representation_id
154    }
155
156    /// Returns the optional semantic input role.
157    #[must_use]
158    pub const fn role(&self) -> Option<&ActivityRole> {
159        self.role.as_ref()
160    }
161}
162
163/// One representation produced by an activity.
164#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
165pub struct ActivityOutput {
166    representation_id: RepresentationId,
167    role: Option<ActivityRole>,
168}
169
170impl ActivityOutput {
171    /// Creates an activity output with an optional extensible role.
172    #[must_use]
173    pub const fn new(representation_id: RepresentationId, role: Option<ActivityRole>) -> Self {
174        Self {
175            representation_id,
176            role,
177        }
178    }
179
180    /// Returns the produced representation.
181    #[must_use]
182    pub const fn representation_id(&self) -> RepresentationId {
183        self.representation_id
184    }
185
186    /// Returns the optional semantic output role.
187    #[must_use]
188    pub const fn role(&self) -> Option<&ActivityRole> {
189        self.role.as_ref()
190    }
191}
192
193/// A production operation connecting input and output representations.
194#[derive(Clone, Debug, Eq, PartialEq)]
195pub struct Activity {
196    id: ActivityId,
197    kind: ActivityKind,
198    started_at: Option<Timestamp>,
199    finished_at: Option<Timestamp>,
200    tool: Option<ToolIdentity>,
201    agent: Option<AgentIdentity>,
202    inputs: Vec<ActivityInput>,
203    outputs: Vec<ActivityOutput>,
204}
205
206impl Activity {
207    /// Creates a complete production activity and canonicalizes its edges.
208    ///
209    /// An activity may have no inputs, but must produce at least one output
210    /// because this model does not expose an in-progress lifecycle state.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`ErrorKind::InvalidArgument`] when edges are empty or
215    /// excessive, an identical edge is duplicated, or one representation
216    /// appears on both sides of the activity.
217    pub fn new(
218        id: ActivityId,
219        kind: ActivityKind,
220        mut inputs: Vec<ActivityInput>,
221        mut outputs: Vec<ActivityOutput>,
222    ) -> Result<Self> {
223        validate_edge_count("input", inputs.len(), true)?;
224        validate_edge_count("output", outputs.len(), false)?;
225        inputs.sort();
226        outputs.sort();
227        if inputs.windows(2).any(|pair| pair[0] == pair[1]) {
228            return Err(Error::new(
229                ErrorKind::InvalidArgument,
230                "activity contains a duplicate input edge",
231            ));
232        }
233        if outputs.windows(2).any(|pair| pair[0] == pair[1]) {
234            return Err(Error::new(
235                ErrorKind::InvalidArgument,
236                "activity contains a duplicate output edge",
237            ));
238        }
239        let input_representations: BTreeSet<_> = inputs
240            .iter()
241            .map(ActivityInput::representation_id)
242            .collect();
243        if outputs
244            .iter()
245            .any(|output| input_representations.contains(&output.representation_id()))
246        {
247            return Err(Error::new(
248                ErrorKind::InvalidArgument,
249                "an activity cannot consume and produce the same representation",
250            ));
251        }
252        Ok(Self {
253            id,
254            kind,
255            started_at: None,
256            finished_at: None,
257            tool: None,
258            agent: None,
259            inputs,
260            outputs,
261        })
262    }
263
264    /// Adds optional activity timing.
265    ///
266    /// # Errors
267    ///
268    /// Returns [`ErrorKind::InvalidArgument`] when both times are present and
269    /// the finish precedes the start.
270    pub fn with_timing(
271        mut self,
272        started_at: Option<Timestamp>,
273        finished_at: Option<Timestamp>,
274    ) -> Result<Self> {
275        if started_at
276            .zip(finished_at)
277            .is_some_and(|(started, finished)| finished < started)
278        {
279            return Err(Error::new(
280                ErrorKind::InvalidArgument,
281                "activity finish time must not precede its start time",
282            ));
283        }
284        self.started_at = started_at;
285        self.finished_at = finished_at;
286        Ok(self)
287    }
288
289    /// Adds the optional tool that performed the activity.
290    #[must_use]
291    pub fn with_tool(mut self, tool: ToolIdentity) -> Self {
292        self.tool = Some(tool);
293        self
294    }
295
296    /// Adds the optional agent responsible for the activity.
297    #[must_use]
298    pub fn with_agent(mut self, agent: AgentIdentity) -> Self {
299        self.agent = Some(agent);
300        self
301    }
302
303    /// Returns the activity's stable identity.
304    #[must_use]
305    pub const fn id(&self) -> ActivityId {
306        self.id
307    }
308
309    /// Returns the extensible activity kind.
310    #[must_use]
311    pub const fn kind(&self) -> &ActivityKind {
312        &self.kind
313    }
314
315    /// Returns the optional start time.
316    #[must_use]
317    pub const fn started_at(&self) -> Option<Timestamp> {
318        self.started_at
319    }
320
321    /// Returns the optional finish time.
322    #[must_use]
323    pub const fn finished_at(&self) -> Option<Timestamp> {
324        self.finished_at
325    }
326
327    /// Returns the optional responsible tool.
328    #[must_use]
329    pub const fn tool(&self) -> Option<&ToolIdentity> {
330        self.tool.as_ref()
331    }
332
333    /// Returns the optional responsible agent.
334    #[must_use]
335    pub const fn agent(&self) -> Option<&AgentIdentity> {
336        self.agent.as_ref()
337    }
338
339    /// Returns inputs in canonical representation/role order.
340    #[must_use]
341    pub fn inputs(&self) -> &[ActivityInput] {
342        &self.inputs
343    }
344
345    /// Returns outputs in canonical representation/role order.
346    #[must_use]
347    pub fn outputs(&self) -> &[ActivityOutput] {
348        &self.outputs
349    }
350}
351
352impl AgentIdentity {
353    /// Creates an agent described by a name, an external identifier, or both.
354    ///
355    /// # Errors
356    ///
357    /// Returns [`ErrorKind::InvalidArgument`] when neither identity is present
358    /// or the name is empty, oversized, or contains NUL.
359    pub fn new(name: Option<String>, identifier: Option<ExternalIdentifier>) -> Result<Self> {
360        if let Some(name) = name.as_deref() {
361            validate_text("agent name", name, MAX_PROVENANCE_NAME_BYTES)?;
362        }
363        if name.is_none() && identifier.is_none() {
364            return Err(Error::new(
365                ErrorKind::InvalidArgument,
366                "agent identity requires a name or external identifier",
367            ));
368        }
369        Ok(Self { name, identifier })
370    }
371
372    /// Returns the optional user-facing agent name.
373    #[must_use]
374    pub fn name(&self) -> Option<&str> {
375        self.name.as_deref()
376    }
377
378    /// Returns the optional external agent identity.
379    #[must_use]
380    pub const fn identifier(&self) -> Option<&ExternalIdentifier> {
381        self.identifier.as_ref()
382    }
383}
384
385fn validate_namespaced_identifier(label: &str, value: String, maximum: usize) -> Result<String> {
386    let valid_bytes = value
387        .bytes()
388        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'));
389    let valid_namespace = value
390        .split_once(':')
391        .is_some_and(|(namespace, local)| !namespace.is_empty() && !local.is_empty());
392    if value.len() > maximum || !valid_bytes || !valid_namespace {
393        return Err(Error::new(
394            ErrorKind::InvalidArgument,
395            format!("{label} must be a namespaced identifier of at most {maximum} ASCII bytes"),
396        ));
397    }
398    Ok(value)
399}
400
401fn validate_text(label: &str, value: &str, maximum: usize) -> Result<()> {
402    if value.is_empty() || value.len() > maximum || value.contains('\0') {
403        return Err(Error::new(
404            ErrorKind::InvalidArgument,
405            format!("{label} must contain 1-{maximum} UTF-8 bytes without NUL"),
406        ));
407    }
408    Ok(())
409}
410
411fn validate_edge_count(label: &str, count: usize, may_be_empty: bool) -> Result<()> {
412    if count > MAX_ACTIVITY_EDGES || (!may_be_empty && count == 0) {
413        let minimum = usize::from(!may_be_empty);
414        return Err(Error::new(
415            ErrorKind::InvalidArgument,
416            format!("activity must have {minimum}-{MAX_ACTIVITY_EDGES} {label} edges"),
417        ));
418    }
419    Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::IdentifierScheme;
426
427    #[test]
428    fn activity_vocabulary_is_extensible_and_namespaced() {
429        let kind = ActivityKind::new("vendor.example:vfx-render").expect("valid kind");
430        let role = ActivityRole::new("vendor.example:input.plate").expect("valid role");
431
432        assert_eq!(kind.as_str(), "vendor.example:vfx-render");
433        assert_eq!(role.as_str(), "vendor.example:input.plate");
434        assert!(ActivityKind::new("transcode").is_err());
435        assert!(ActivityRole::new("vendor.example:bad role").is_err());
436    }
437
438    #[test]
439    fn tool_and_agent_identity_preserve_supplied_detail() {
440        let tool = ToolIdentity::new(
441            "FFmpeg",
442            Some("8.0-custom".to_owned()),
443            Some("https://ffmpeg.org".to_owned()),
444        )
445        .expect("valid tool");
446        let identifier = ExternalIdentifier::new(
447            IdentifierScheme::new("com.example.worker").expect("valid scheme"),
448            "worker-42",
449            None,
450        )
451        .expect("valid identifier");
452        let agent = AgentIdentity::new(Some("Render worker".to_owned()), Some(identifier.clone()))
453            .expect("valid agent");
454
455        assert_eq!(tool.name(), "FFmpeg");
456        assert_eq!(tool.version(), Some("8.0-custom"));
457        assert_eq!(tool.uri(), Some("https://ffmpeg.org/"));
458        assert_eq!(agent.name(), Some("Render worker"));
459        assert_eq!(agent.identifier(), Some(&identifier));
460    }
461
462    #[test]
463    fn participant_identity_is_bounded() {
464        assert!(ToolIdentity::new("", None, None).is_err());
465        assert!(ToolIdentity::new("tool", Some(String::new()), None).is_err());
466        assert!(ToolIdentity::new("tool", None, Some("relative".to_owned())).is_err());
467        let oversized_uri = format!(
468            "https://example.com/{}",
469            "x".repeat(MAX_PROVENANCE_URI_BYTES)
470        );
471        assert!(ToolIdentity::new("tool", None, Some(oversized_uri)).is_err());
472        assert!(AgentIdentity::new(None, None).is_err());
473        assert!(AgentIdentity::new(Some("bad\0name".to_owned()), None).is_err());
474    }
475
476    #[test]
477    fn activity_supports_canonical_fan_in_and_fan_out() {
478        let first_input = RepresentationId::from_bytes([2; 16]);
479        let second_input = RepresentationId::from_bytes([1; 16]);
480        let first_output = RepresentationId::from_bytes([4; 16]);
481        let second_output = RepresentationId::from_bytes([3; 16]);
482        let activity = Activity::new(
483            ActivityId::new(),
484            ActivityKind::new("org.postproject:transcode").expect("valid kind"),
485            vec![
486                ActivityInput::new(first_input, None),
487                ActivityInput::new(second_input, None),
488            ],
489            vec![
490                ActivityOutput::new(first_output, None),
491                ActivityOutput::new(second_output, None),
492            ],
493        )
494        .expect("valid activity")
495        .with_timing(
496            Some(Timestamp::from_unix_micros(10)),
497            Some(Timestamp::from_unix_micros(20)),
498        )
499        .expect("valid timing");
500
501        assert_eq!(activity.inputs()[0].representation_id(), second_input);
502        assert_eq!(activity.inputs()[1].representation_id(), first_input);
503        assert_eq!(activity.outputs()[0].representation_id(), second_output);
504        assert_eq!(activity.outputs()[1].representation_id(), first_output);
505    }
506
507    #[test]
508    fn activity_rejects_incomplete_or_ambiguous_graph_facts() {
509        let representation = RepresentationId::new();
510        let kind = || ActivityKind::new("org.postproject:vfx-render").expect("valid kind");
511        let input = || ActivityInput::new(representation, None);
512        let output = || ActivityOutput::new(representation, None);
513
514        let no_outputs = Activity::new(ActivityId::new(), kind(), vec![input()], Vec::new());
515        assert!(no_outputs.is_err());
516
517        let duplicate = Activity::new(
518            ActivityId::new(),
519            kind(),
520            Vec::new(),
521            vec![output(), output()],
522        );
523        assert!(duplicate.is_err());
524
525        let self_edge = Activity::new(ActivityId::new(), kind(), vec![input()], vec![output()]);
526        assert!(self_edge.is_err());
527
528        let reversed_time = Activity::new(
529            ActivityId::new(),
530            kind(),
531            Vec::new(),
532            vec![ActivityOutput::new(RepresentationId::new(), None)],
533        )
534        .expect("valid activity")
535        .with_timing(
536            Some(Timestamp::from_unix_micros(2)),
537            Some(Timestamp::from_unix_micros(1)),
538        );
539        assert!(reversed_time.is_err());
540    }
541}