Skip to main content

relay_knowledge/domain/operations/
runtime.rs

1use serde::{Deserialize, Serialize};
2
3use super::{DomainError, GraphVersion, error::required_text};
4
5/// External or fallback worker family used by background productization tasks.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum WorkerKind {
9    Embedding,
10    Ocr,
11    Vision,
12    Extractor,
13}
14
15impl WorkerKind {
16    pub const ALL: [Self; 4] = [Self::Embedding, Self::Ocr, Self::Vision, Self::Extractor];
17
18    /// Stable storage and API representation.
19    pub const fn as_str(self) -> &'static str {
20        match self {
21            Self::Embedding => "embedding",
22            Self::Ocr => "ocr",
23            Self::Vision => "vision",
24            Self::Extractor => "extractor",
25        }
26    }
27
28    /// Parses the stable storage and API representation.
29    pub fn parse(value: &str) -> Result<Self, DomainError> {
30        match value {
31            "embedding" => Ok(Self::Embedding),
32            "ocr" => Ok(Self::Ocr),
33            "vision" => Ok(Self::Vision),
34            "extractor" => Ok(Self::Extractor),
35            _ => Err(DomainError::invalid("worker_kind", "unknown worker kind")),
36        }
37    }
38}
39
40/// Persistent task lifecycle for bounded worker queues.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum WorkerTaskState {
44    Queued,
45    Running,
46    Succeeded,
47    Retrying,
48    Failed,
49    DeadLetter,
50}
51
52impl WorkerTaskState {
53    /// Stable storage and API representation.
54    pub const fn as_str(self) -> &'static str {
55        match self {
56            Self::Queued => "queued",
57            Self::Running => "running",
58            Self::Succeeded => "succeeded",
59            Self::Retrying => "retrying",
60            Self::Failed => "failed",
61            Self::DeadLetter => "dead_letter",
62        }
63    }
64
65    /// Parses the stable storage and API representation.
66    pub fn parse(value: &str) -> Result<Self, DomainError> {
67        match value {
68            "queued" => Ok(Self::Queued),
69            "running" => Ok(Self::Running),
70            "succeeded" => Ok(Self::Succeeded),
71            "retrying" => Ok(Self::Retrying),
72            "failed" => Ok(Self::Failed),
73            "dead_letter" => Ok(Self::DeadLetter),
74            _ => Err(DomainError::invalid(
75                "worker_task_state",
76                "unknown worker task state",
77            )),
78        }
79    }
80}
81
82/// Runtime availability of an external worker backend.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum WorkerBackendState {
86    Fallback,
87    Configured,
88    Degraded,
89    Unavailable,
90}
91
92impl WorkerBackendState {
93    /// Stable API representation.
94    pub const fn as_str(self) -> &'static str {
95        match self {
96            Self::Fallback => "fallback",
97            Self::Configured => "configured",
98            Self::Degraded => "degraded",
99            Self::Unavailable => "unavailable",
100        }
101    }
102}
103
104/// Persistent worker task used by service, CLI, Web, and recovery diagnostics.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct WorkerTaskRecord {
107    pub task_id: String,
108    pub kind: WorkerKind,
109    pub source_scope: String,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub evidence_id: Option<String>,
112    pub target_graph_version: GraphVersion,
113    pub state: WorkerTaskState,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub lease_owner: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub lease_expires_at_ms: Option<u64>,
118    pub attempt_count: u32,
119    pub next_retry_at_ms: u64,
120    pub input_fingerprint: String,
121    pub payload_json: String,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub last_error_kind: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub last_error_message: Option<String>,
126    pub created_at_ms: u64,
127    pub updated_at_ms: u64,
128}
129
130/// Aggregated status for a worker family.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct WorkerStatus {
133    pub kind: WorkerKind,
134    pub backend_state: WorkerBackendState,
135    pub endpoint_configured: bool,
136    pub queue_depth: usize,
137    pub running_count: usize,
138    pub retrying_count: usize,
139    pub dead_letter_count: usize,
140    pub last_error: Option<String>,
141}
142
143/// Proposal fact family stored before user approval.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum ProposalKind {
147    Evidence,
148    Relation,
149    Claim,
150    Event,
151}
152
153impl ProposalKind {
154    /// Stable storage and API representation.
155    pub const fn as_str(self) -> &'static str {
156        match self {
157            Self::Evidence => "evidence",
158            Self::Relation => "relation",
159            Self::Claim => "claim",
160            Self::Event => "event",
161        }
162    }
163
164    /// Parses the stable storage and API representation.
165    pub fn parse(value: &str) -> Result<Self, DomainError> {
166        match value {
167            "evidence" => Ok(Self::Evidence),
168            "relation" => Ok(Self::Relation),
169            "claim" => Ok(Self::Claim),
170            "event" => Ok(Self::Event),
171            _ => Err(DomainError::invalid(
172                "proposal_kind",
173                "unknown proposal kind",
174            )),
175        }
176    }
177}
178
179/// Proposal approval lifecycle.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum ProposalState {
183    Proposed,
184    Accepted,
185    Rejected,
186    Superseded,
187}
188
189impl ProposalState {
190    /// Stable storage and API representation.
191    pub const fn as_str(self) -> &'static str {
192        match self {
193            Self::Proposed => "proposed",
194            Self::Accepted => "accepted",
195            Self::Rejected => "rejected",
196            Self::Superseded => "superseded",
197        }
198    }
199
200    /// Parses the stable storage and API representation.
201    pub fn parse(value: &str) -> Result<Self, DomainError> {
202        match value {
203            "proposed" => Ok(Self::Proposed),
204            "accepted" => Ok(Self::Accepted),
205            "rejected" => Ok(Self::Rejected),
206            "superseded" => Ok(Self::Superseded),
207            _ => Err(DomainError::invalid(
208                "proposal_state",
209                "unknown proposal state",
210            )),
211        }
212    }
213}
214
215/// Conflict severity shown before manual approval.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum ProposalConflictSeverity {
219    Info,
220    Warning,
221    Blocking,
222}
223
224impl ProposalConflictSeverity {
225    /// Stable storage and API representation.
226    pub const fn as_str(self) -> &'static str {
227        match self {
228            Self::Info => "info",
229            Self::Warning => "warning",
230            Self::Blocking => "blocking",
231        }
232    }
233
234    /// Parses the stable storage and API representation.
235    pub fn parse(value: &str) -> Result<Self, DomainError> {
236        match value {
237            "info" => Ok(Self::Info),
238            "warning" => Ok(Self::Warning),
239            "blocking" => Ok(Self::Blocking),
240            _ => Err(DomainError::invalid(
241                "proposal_conflict_severity",
242                "unknown proposal conflict severity",
243            )),
244        }
245    }
246}
247
248/// Stored proposal ready for CLI/Web review.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct ProposalRecord {
251    pub proposal_id: String,
252    pub source_scope: String,
253    pub kind: ProposalKind,
254    pub state: ProposalState,
255    pub title: String,
256    pub summary: String,
257    pub payload_json: String,
258    pub origin: String,
259    pub provenance: ProposalProvenance,
260    pub confidence_basis_points: u16,
261    pub conflict_count: usize,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub decided_by: Option<String>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub decision_reason: Option<String>,
266    pub created_at_ms: u64,
267    pub updated_at_ms: u64,
268}
269
270impl ProposalRecord {
271    /// Returns proposal payload as JSON for API consumers that need typed preview.
272    pub fn payload_value(&self) -> serde_json::Value {
273        serde_json::from_str(&self.payload_json).unwrap_or(serde_json::Value::Null)
274    }
275}
276
277/// Auditable model, prompt, and source lineage for a stored proposal.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct ProposalProvenance {
280    pub producer: String,
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub provider: Option<String>,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub model: Option<String>,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub prompt_id: Option<String>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub prompt_version: Option<String>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub schema_version: Option<String>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub input_source_hash: Option<String>,
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub input_fact_ids: Vec<String>,
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub stale_when: Vec<String>,
297    #[serde(default, skip_serializing_if = "Vec::is_empty")]
298    pub budget_notes: Vec<String>,
299}
300
301impl Default for ProposalProvenance {
302    fn default() -> Self {
303        Self::new("unspecified")
304    }
305}
306
307impl ProposalProvenance {
308    /// Creates a minimal provenance record for deterministic or manual proposal producers.
309    pub fn new(producer: impl Into<String>) -> Self {
310        Self {
311            producer: producer.into(),
312            provider: None,
313            model: None,
314            prompt_id: None,
315            prompt_version: None,
316            schema_version: None,
317            input_source_hash: None,
318            input_fact_ids: Vec::new(),
319            stale_when: Vec::new(),
320            budget_notes: Vec::new(),
321        }
322    }
323
324    /// Parses stored JSON while preserving legacy rows that predate provenance metadata.
325    pub fn from_json(value: &str) -> Result<Self, DomainError> {
326        if value.trim().is_empty() || value.trim() == "{}" {
327            return Ok(Self::default());
328        }
329
330        serde_json::from_str::<Self>(value)
331            .map_err(|_| DomainError::invalid("proposal_provenance", "must be valid JSON"))?
332            .validate()
333    }
334
335    /// Serializes provenance metadata for storage.
336    pub fn to_json(&self) -> String {
337        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_owned())
338    }
339
340    /// Normalizes and validates stable provenance fields.
341    pub fn validate(mut self) -> Result<Self, DomainError> {
342        self.producer = required_text("proposal_producer", self.producer)?;
343        self.provider = normalize_optional_text("proposal_provider", self.provider)?;
344        self.model = normalize_optional_text("proposal_model", self.model)?;
345        self.prompt_id = normalize_optional_text("proposal_prompt_id", self.prompt_id)?;
346        self.prompt_version =
347            normalize_optional_text("proposal_prompt_version", self.prompt_version)?;
348        self.schema_version =
349            normalize_optional_text("proposal_schema_version", self.schema_version)?;
350        self.input_source_hash =
351            normalize_optional_text("proposal_input_source_hash", self.input_source_hash)?;
352        self.input_fact_ids = normalize_text_list("proposal_input_fact_id", self.input_fact_ids)?;
353        self.stale_when = normalize_text_list("proposal_stale_condition", self.stale_when)?;
354        self.budget_notes = normalize_text_list("proposal_budget_note", self.budget_notes)?;
355
356        Ok(self)
357    }
358}
359
360/// Stored conflict associated with a proposal.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct ProposalConflictRecord {
363    pub conflict_id: String,
364    pub proposal_id: String,
365    pub existing_fact_kind: String,
366    pub existing_fact_id: String,
367    pub severity: ProposalConflictSeverity,
368    pub reason: String,
369}
370
371/// Persistent audit status shared by CLI/Web/service/agent surfaces.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub enum AuditStatus {
375    Started,
376    Completed,
377    Failed,
378    Cancelled,
379}
380
381impl AuditStatus {
382    /// Stable storage and API representation.
383    pub const fn as_str(self) -> &'static str {
384        match self {
385            Self::Started => "started",
386            Self::Completed => "completed",
387            Self::Failed => "failed",
388            Self::Cancelled => "cancelled",
389        }
390    }
391
392    /// Parses the stable storage and API representation.
393    pub fn parse(value: &str) -> Result<Self, DomainError> {
394        match value {
395            "started" => Ok(Self::Started),
396            "completed" => Ok(Self::Completed),
397            "failed" => Ok(Self::Failed),
398            "cancelled" => Ok(Self::Cancelled),
399            _ => Err(DomainError::invalid("audit_status", "unknown audit status")),
400        }
401    }
402}
403
404/// Redacted durable audit event.
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct AuditEventRecord {
407    pub sequence: u64,
408    pub operation: String,
409    pub interface: String,
410    pub request_id: String,
411    pub trace_id: String,
412    pub status: AuditStatus,
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub actor: Option<String>,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub source_scope: Option<String>,
417    pub graph_version: u64,
418    pub detail_json: String,
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub message: Option<String>,
421    pub created_at_ms: u64,
422}
423
424/// Installed background operator state.
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
426#[serde(rename_all = "snake_case")]
427pub enum ServiceOperatorState {
428    Disabled,
429    Enabled,
430    Paused,
431    Degraded,
432    Failed,
433}
434
435impl ServiceOperatorState {
436    /// Stable storage and API representation.
437    pub const fn as_str(self) -> &'static str {
438        match self {
439            Self::Disabled => "disabled",
440            Self::Enabled => "enabled",
441            Self::Paused => "paused",
442            Self::Degraded => "degraded",
443            Self::Failed => "failed",
444        }
445    }
446
447    /// Parses the stable storage and API representation.
448    pub fn parse(value: &str) -> Result<Self, DomainError> {
449        match value {
450            "disabled" => Ok(Self::Disabled),
451            "enabled" => Ok(Self::Enabled),
452            "paused" => Ok(Self::Paused),
453            "degraded" => Ok(Self::Degraded),
454            "failed" => Ok(Self::Failed),
455            _ => Err(DomainError::invalid(
456                "service_operator_state",
457                "unknown service operator state",
458            )),
459        }
460    }
461}
462
463/// Persisted silent-update operator status.
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct ServiceOperatorStatus {
466    pub state: ServiceOperatorState,
467    pub silent_updates_enabled: bool,
468    pub allowed_scopes: Vec<String>,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub last_run_at_ms: Option<u64>,
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub next_retry_at_ms: Option<u64>,
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub last_error: Option<String>,
475    pub updated_at_ms: u64,
476}
477
478/// Service manager action surfaced as a generated, user-executed plan.
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481pub enum ServiceManagerAction {
482    Install,
483    Uninstall,
484}
485
486impl ServiceManagerAction {
487    /// Stable CLI and API representation.
488    pub const fn as_str(self) -> &'static str {
489        match self {
490            Self::Install => "install",
491            Self::Uninstall => "uninstall",
492        }
493    }
494
495    /// Parses the stable CLI and API representation.
496    pub fn parse(value: &str) -> Result<Self, DomainError> {
497        match value {
498            "install" => Ok(Self::Install),
499            "uninstall" => Ok(Self::Uninstall),
500            _ => Err(DomainError::invalid(
501                "service_manager_action",
502                "unknown service manager action",
503            )),
504        }
505    }
506}
507
508/// Service definition rendering without privileged execution.
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
510pub struct ServiceDefinitionPlan {
511    pub action: ServiceManagerAction,
512    pub platform: String,
513    pub service_name: String,
514    pub definition_path: String,
515    pub definition: String,
516    pub install_command: Vec<String>,
517    pub uninstall_command: Vec<String>,
518    pub start_command: Vec<String>,
519    pub stop_command: Vec<String>,
520    pub checksum: String,
521}
522
523/// Normalizes a required actor identifier for lifecycle decisions.
524pub fn normalize_actor(value: impl Into<String>) -> Result<String, DomainError> {
525    required_text("actor", value)
526}
527
528fn normalize_optional_text(
529    field: &'static str,
530    value: Option<String>,
531) -> Result<Option<String>, DomainError> {
532    value.map(|inner| required_text(field, inner)).transpose()
533}
534
535fn normalize_text_list(
536    field: &'static str,
537    values: Vec<String>,
538) -> Result<Vec<String>, DomainError> {
539    let mut normalized = Vec::new();
540    for value in values {
541        let value = required_text(field, value)?;
542        if !normalized.contains(&value) {
543            normalized.push(value);
544        }
545    }
546
547    Ok(normalized)
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn operational_enums_have_stable_storage_values() {
556        for (kind, value) in [
557            (WorkerKind::Embedding, "embedding"),
558            (WorkerKind::Ocr, "ocr"),
559            (WorkerKind::Vision, "vision"),
560            (WorkerKind::Extractor, "extractor"),
561        ] {
562            assert_eq!(kind.as_str(), value);
563            assert_eq!(WorkerKind::parse(value).expect("worker kind"), kind);
564        }
565        for (state, value) in [
566            (WorkerTaskState::Queued, "queued"),
567            (WorkerTaskState::Running, "running"),
568            (WorkerTaskState::Succeeded, "succeeded"),
569            (WorkerTaskState::Retrying, "retrying"),
570            (WorkerTaskState::Failed, "failed"),
571            (WorkerTaskState::DeadLetter, "dead_letter"),
572        ] {
573            assert_eq!(state.as_str(), value);
574            assert_eq!(WorkerTaskState::parse(value).expect("task state"), state);
575        }
576        for (state, value) in [
577            (WorkerBackendState::Fallback, "fallback"),
578            (WorkerBackendState::Configured, "configured"),
579            (WorkerBackendState::Degraded, "degraded"),
580            (WorkerBackendState::Unavailable, "unavailable"),
581        ] {
582            assert_eq!(state.as_str(), value);
583        }
584    }
585
586    #[test]
587    fn proposal_audit_and_operator_enums_have_stable_values() {
588        for (kind, value) in [
589            (ProposalKind::Evidence, "evidence"),
590            (ProposalKind::Relation, "relation"),
591            (ProposalKind::Claim, "claim"),
592            (ProposalKind::Event, "event"),
593        ] {
594            assert_eq!(kind.as_str(), value);
595            assert_eq!(ProposalKind::parse(value).expect("proposal kind"), kind);
596        }
597        for (state, value) in [
598            (ProposalState::Proposed, "proposed"),
599            (ProposalState::Accepted, "accepted"),
600            (ProposalState::Rejected, "rejected"),
601            (ProposalState::Superseded, "superseded"),
602        ] {
603            assert_eq!(state.as_str(), value);
604            assert_eq!(ProposalState::parse(value).expect("proposal state"), state);
605        }
606        for (severity, value) in [
607            (ProposalConflictSeverity::Info, "info"),
608            (ProposalConflictSeverity::Warning, "warning"),
609            (ProposalConflictSeverity::Blocking, "blocking"),
610        ] {
611            assert_eq!(severity.as_str(), value);
612            assert_eq!(
613                ProposalConflictSeverity::parse(value).expect("conflict severity"),
614                severity
615            );
616        }
617        for (status, value) in [
618            (AuditStatus::Started, "started"),
619            (AuditStatus::Completed, "completed"),
620            (AuditStatus::Failed, "failed"),
621            (AuditStatus::Cancelled, "cancelled"),
622        ] {
623            assert_eq!(status.as_str(), value);
624            assert_eq!(AuditStatus::parse(value).expect("audit status"), status);
625        }
626        for (state, value) in [
627            (ServiceOperatorState::Disabled, "disabled"),
628            (ServiceOperatorState::Enabled, "enabled"),
629            (ServiceOperatorState::Paused, "paused"),
630            (ServiceOperatorState::Degraded, "degraded"),
631            (ServiceOperatorState::Failed, "failed"),
632        ] {
633            assert_eq!(state.as_str(), value);
634            assert_eq!(
635                ServiceOperatorState::parse(value).expect("operator state"),
636                state
637            );
638        }
639        for (action, value) in [
640            (ServiceManagerAction::Install, "install"),
641            (ServiceManagerAction::Uninstall, "uninstall"),
642        ] {
643            assert_eq!(action.as_str(), value);
644            assert_eq!(
645                ServiceManagerAction::parse(value).expect("service action"),
646                action
647            );
648        }
649    }
650
651    #[test]
652    fn invalid_operational_values_are_rejected_or_redacted() {
653        assert!(WorkerKind::parse("gpu").is_err());
654        assert!(ProposalState::parse("merged").is_err());
655        assert!(AuditStatus::parse("pending").is_err());
656        assert!(ServiceManagerAction::parse("restart").is_err());
657        assert!(normalize_actor("  ").is_err());
658
659        let proposal = ProposalRecord {
660            proposal_id: "proposal:test".to_owned(),
661            source_scope: "docs".to_owned(),
662            kind: ProposalKind::Evidence,
663            state: ProposalState::Proposed,
664            title: "title".to_owned(),
665            summary: "summary".to_owned(),
666            payload_json: "{".to_owned(),
667            origin: "test".to_owned(),
668            provenance: ProposalProvenance::new("test"),
669            confidence_basis_points: 1,
670            conflict_count: 0,
671            decided_by: None,
672            decision_reason: None,
673            created_at_ms: 1,
674            updated_at_ms: 1,
675        };
676
677        assert!(proposal.payload_value().is_null());
678    }
679
680    #[test]
681    fn proposal_provenance_normalizes_and_validates_lineage() {
682        let provenance = ProposalProvenance {
683            producer: " llm_spo_extraction ".to_owned(),
684            provider: Some(" openai-compatible ".to_owned()),
685            model: Some(" graph-extractor ".to_owned()),
686            prompt_id: Some(" relay.extract.spo ".to_owned()),
687            prompt_version: Some(" 1 ".to_owned()),
688            schema_version: Some(" worker-proposal.v2 ".to_owned()),
689            input_source_hash: Some(" sha256:source ".to_owned()),
690            input_fact_ids: vec![" ev-1 ".to_owned(), "ev-1".to_owned()],
691            stale_when: vec![" graph_version_advances ".to_owned()],
692            budget_notes: vec![" timeout_ms=30000 ".to_owned()],
693        }
694        .validate()
695        .expect("provenance should validate");
696
697        assert_eq!(provenance.producer, "llm_spo_extraction");
698        assert_eq!(provenance.input_fact_ids, ["ev-1"]);
699        assert_eq!(
700            ProposalProvenance::from_json(&provenance.to_json())
701                .expect("stored provenance should parse"),
702            provenance
703        );
704        assert_eq!(
705            ProposalProvenance::from_json("{}")
706                .expect("legacy provenance should default")
707                .producer,
708            "unspecified"
709        );
710        assert_eq!(
711            ProposalProvenance::new(" ")
712                .validate()
713                .expect_err("empty producer should fail")
714                .field,
715            "proposal_producer"
716        );
717    }
718}