Skip to main content

relay_knowledge/domain/operations/runtime/
mod.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    Upgrade,
484    Rollback,
485    Uninstall,
486}
487
488impl ServiceManagerAction {
489    /// Stable CLI and API representation.
490    pub const fn as_str(self) -> &'static str {
491        match self {
492            Self::Install => "install",
493            Self::Upgrade => "upgrade",
494            Self::Rollback => "rollback",
495            Self::Uninstall => "uninstall",
496        }
497    }
498
499    /// Parses the stable CLI and API representation.
500    pub fn parse(value: &str) -> Result<Self, DomainError> {
501        match value {
502            "install" => Ok(Self::Install),
503            "upgrade" => Ok(Self::Upgrade),
504            "rollback" => Ok(Self::Rollback),
505            "uninstall" => Ok(Self::Uninstall),
506            _ => Err(DomainError::invalid(
507                "service_manager_action",
508                "unknown service manager action",
509            )),
510        }
511    }
512}
513
514/// Service definition rendering without privileged execution.
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub struct ServiceDefinitionPlan {
517    pub action: ServiceManagerAction,
518    pub dry_run: bool,
519    pub platform: String,
520    pub service_name: String,
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub target_version: Option<String>,
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub install_dir: Option<String>,
525    pub binary_path: String,
526    pub definition_path: String,
527    pub definition: String,
528    pub install_command: Vec<String>,
529    pub uninstall_command: Vec<String>,
530    pub start_command: Vec<String>,
531    pub stop_command: Vec<String>,
532    pub lifecycle_steps: Vec<ServiceLifecycleStep>,
533    pub rollback_steps: Vec<ServiceLifecycleStep>,
534    pub permission_requirements: Vec<ServicePermissionRequirement>,
535    pub package_manifest_checks: Vec<ServicePackageManifestCheck>,
536    pub runtime_state_paths: Vec<String>,
537    pub checkpoint_path: String,
538    pub warnings: Vec<String>,
539    pub checksum: String,
540}
541
542/// One staged service lifecycle action.
543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
544pub struct ServiceLifecycleStep {
545    pub id: String,
546    pub phase: String,
547    pub description: String,
548    pub command: Vec<String>,
549    pub writes_paths: Vec<String>,
550    pub removes_paths: Vec<String>,
551    pub requires_privilege: bool,
552}
553
554/// Permission requirement attached to a generated service lifecycle plan.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556pub struct ServicePermissionRequirement {
557    pub scope: String,
558    pub reason: String,
559}
560
561/// Package-manager release-manifest drift check described by the lifecycle plan.
562#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
563pub struct ServicePackageManifestCheck {
564    pub manager: String,
565    pub artifact_source: String,
566    pub verification: String,
567}
568
569/// Execution result for a lifecycle step.
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571pub struct ServiceLifecycleStepResult {
572    pub step_id: String,
573    pub status: String,
574    pub message: String,
575}
576
577/// Result of executing or dry-running a service lifecycle plan.
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579pub struct ServiceLifecycleExecutionReport {
580    pub executed: bool,
581    pub dry_run: bool,
582    pub completed_steps: Vec<ServiceLifecycleStepResult>,
583    pub rollback_steps: Vec<ServiceLifecycleStepResult>,
584    pub rolled_back: bool,
585    #[serde(skip_serializing_if = "Option::is_none")]
586    pub failed_step_id: Option<String>,
587}
588
589/// Normalizes a required actor identifier for lifecycle decisions.
590pub fn normalize_actor(value: impl Into<String>) -> Result<String, DomainError> {
591    required_text("actor", value)
592}
593
594fn normalize_optional_text(
595    field: &'static str,
596    value: Option<String>,
597) -> Result<Option<String>, DomainError> {
598    value.map(|inner| required_text(field, inner)).transpose()
599}
600
601fn normalize_text_list(
602    field: &'static str,
603    values: Vec<String>,
604) -> Result<Vec<String>, DomainError> {
605    let mut normalized = Vec::new();
606    for value in values {
607        let value = required_text(field, value)?;
608        if !normalized.contains(&value) {
609            normalized.push(value);
610        }
611    }
612
613    Ok(normalized)
614}
615
616#[cfg(test)]
617#[path = "mod_tests.rs"]
618mod tests;