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 checksum: String,
521}
522
523pub 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}