1use serde::{Deserialize, Serialize};
2
3use super::{DomainError, GraphVersion, error::required_text};
4
5#[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 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 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#[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 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 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#[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 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#[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#[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#[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 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 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#[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 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 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#[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 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 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#[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 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#[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 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 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 pub fn to_json(&self) -> String {
337 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_owned())
338 }
339
340 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#[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#[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 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 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#[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#[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 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 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#[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#[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 pub const fn as_str(self) -> &'static str {
489 match self {
490 Self::Install => "install",
491 Self::Uninstall => "uninstall",
492 }
493 }
494
495 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#[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 runtime_state_paths: Vec<String>,
521 pub warnings: Vec<String>,
522 pub checksum: String,
523}
524
525pub fn normalize_actor(value: impl Into<String>) -> Result<String, DomainError> {
527 required_text("actor", value)
528}
529
530fn normalize_optional_text(
531 field: &'static str,
532 value: Option<String>,
533) -> Result<Option<String>, DomainError> {
534 value.map(|inner| required_text(field, inner)).transpose()
535}
536
537fn normalize_text_list(
538 field: &'static str,
539 values: Vec<String>,
540) -> Result<Vec<String>, DomainError> {
541 let mut normalized = Vec::new();
542 for value in values {
543 let value = required_text(field, value)?;
544 if !normalized.contains(&value) {
545 normalized.push(value);
546 }
547 }
548
549 Ok(normalized)
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn operational_enums_have_stable_storage_values() {
558 for (kind, value) in [
559 (WorkerKind::Embedding, "embedding"),
560 (WorkerKind::Ocr, "ocr"),
561 (WorkerKind::Vision, "vision"),
562 (WorkerKind::Extractor, "extractor"),
563 ] {
564 assert_eq!(kind.as_str(), value);
565 assert_eq!(WorkerKind::parse(value).expect("worker kind"), kind);
566 }
567 for (state, value) in [
568 (WorkerTaskState::Queued, "queued"),
569 (WorkerTaskState::Running, "running"),
570 (WorkerTaskState::Succeeded, "succeeded"),
571 (WorkerTaskState::Retrying, "retrying"),
572 (WorkerTaskState::Failed, "failed"),
573 (WorkerTaskState::DeadLetter, "dead_letter"),
574 ] {
575 assert_eq!(state.as_str(), value);
576 assert_eq!(WorkerTaskState::parse(value).expect("task state"), state);
577 }
578 for (state, value) in [
579 (WorkerBackendState::Fallback, "fallback"),
580 (WorkerBackendState::Configured, "configured"),
581 (WorkerBackendState::Degraded, "degraded"),
582 (WorkerBackendState::Unavailable, "unavailable"),
583 ] {
584 assert_eq!(state.as_str(), value);
585 }
586 }
587
588 #[test]
589 fn proposal_audit_and_operator_enums_have_stable_values() {
590 for (kind, value) in [
591 (ProposalKind::Evidence, "evidence"),
592 (ProposalKind::Relation, "relation"),
593 (ProposalKind::Claim, "claim"),
594 (ProposalKind::Event, "event"),
595 ] {
596 assert_eq!(kind.as_str(), value);
597 assert_eq!(ProposalKind::parse(value).expect("proposal kind"), kind);
598 }
599 for (state, value) in [
600 (ProposalState::Proposed, "proposed"),
601 (ProposalState::Accepted, "accepted"),
602 (ProposalState::Rejected, "rejected"),
603 (ProposalState::Superseded, "superseded"),
604 ] {
605 assert_eq!(state.as_str(), value);
606 assert_eq!(ProposalState::parse(value).expect("proposal state"), state);
607 }
608 for (severity, value) in [
609 (ProposalConflictSeverity::Info, "info"),
610 (ProposalConflictSeverity::Warning, "warning"),
611 (ProposalConflictSeverity::Blocking, "blocking"),
612 ] {
613 assert_eq!(severity.as_str(), value);
614 assert_eq!(
615 ProposalConflictSeverity::parse(value).expect("conflict severity"),
616 severity
617 );
618 }
619 for (status, value) in [
620 (AuditStatus::Started, "started"),
621 (AuditStatus::Completed, "completed"),
622 (AuditStatus::Failed, "failed"),
623 (AuditStatus::Cancelled, "cancelled"),
624 ] {
625 assert_eq!(status.as_str(), value);
626 assert_eq!(AuditStatus::parse(value).expect("audit status"), status);
627 }
628 for (state, value) in [
629 (ServiceOperatorState::Disabled, "disabled"),
630 (ServiceOperatorState::Enabled, "enabled"),
631 (ServiceOperatorState::Paused, "paused"),
632 (ServiceOperatorState::Degraded, "degraded"),
633 (ServiceOperatorState::Failed, "failed"),
634 ] {
635 assert_eq!(state.as_str(), value);
636 assert_eq!(
637 ServiceOperatorState::parse(value).expect("operator state"),
638 state
639 );
640 }
641 for (action, value) in [
642 (ServiceManagerAction::Install, "install"),
643 (ServiceManagerAction::Uninstall, "uninstall"),
644 ] {
645 assert_eq!(action.as_str(), value);
646 assert_eq!(
647 ServiceManagerAction::parse(value).expect("service action"),
648 action
649 );
650 }
651 }
652
653 #[test]
654 fn invalid_operational_values_are_rejected_or_redacted() {
655 assert!(WorkerKind::parse("gpu").is_err());
656 assert!(ProposalState::parse("merged").is_err());
657 assert!(AuditStatus::parse("pending").is_err());
658 assert!(ServiceManagerAction::parse("restart").is_err());
659 assert!(normalize_actor(" ").is_err());
660
661 let proposal = ProposalRecord {
662 proposal_id: "proposal:test".to_owned(),
663 source_scope: "docs".to_owned(),
664 kind: ProposalKind::Evidence,
665 state: ProposalState::Proposed,
666 title: "title".to_owned(),
667 summary: "summary".to_owned(),
668 payload_json: "{".to_owned(),
669 origin: "test".to_owned(),
670 provenance: ProposalProvenance::new("test"),
671 confidence_basis_points: 1,
672 conflict_count: 0,
673 decided_by: None,
674 decision_reason: None,
675 created_at_ms: 1,
676 updated_at_ms: 1,
677 };
678
679 assert!(proposal.payload_value().is_null());
680 }
681
682 #[test]
683 fn proposal_provenance_normalizes_and_validates_lineage() {
684 let provenance = ProposalProvenance {
685 producer: " llm_spo_extraction ".to_owned(),
686 provider: Some(" openai-compatible ".to_owned()),
687 model: Some(" graph-extractor ".to_owned()),
688 prompt_id: Some(" relay.extract.spo ".to_owned()),
689 prompt_version: Some(" 1 ".to_owned()),
690 schema_version: Some(" worker-proposal.v2 ".to_owned()),
691 input_source_hash: Some(" sha256:source ".to_owned()),
692 input_fact_ids: vec![" ev-1 ".to_owned(), "ev-1".to_owned()],
693 stale_when: vec![" graph_version_advances ".to_owned()],
694 budget_notes: vec![" timeout_ms=30000 ".to_owned()],
695 }
696 .validate()
697 .expect("provenance should validate");
698
699 assert_eq!(provenance.producer, "llm_spo_extraction");
700 assert_eq!(provenance.input_fact_ids, ["ev-1"]);
701 assert_eq!(
702 ProposalProvenance::from_json(&provenance.to_json())
703 .expect("stored provenance should parse"),
704 provenance
705 );
706 assert_eq!(
707 ProposalProvenance::from_json("{}")
708 .expect("legacy provenance should default")
709 .producer,
710 "unspecified"
711 );
712 assert_eq!(
713 ProposalProvenance::new(" ")
714 .validate()
715 .expect_err("empty producer should fail")
716 .field,
717 "proposal_producer"
718 );
719 }
720}