runx_contracts/
source_packet.rs1use serde::{Deserialize, Serialize};
13
14use crate::operational_proposal::OperationalProposalRedactionStatus;
15use crate::schema::{IsoDateTime, NonEmptyString, RunxSchema};
16use crate::{Fingerprint, JsonObject, Reference, SignalAuthenticity};
17
18pub const SOURCE_PACKET_SCHEMA: &str = "runx.source_packet.v1";
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
21pub enum SourcePacketSchema {
22 #[serde(rename = "runx.source_packet.v1")]
23 V1,
24}
25
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
27#[serde(deny_unknown_fields)]
28#[runx_schema(id = "runx.source_packet.v1")]
29pub struct SourcePacket {
30 pub schema: SourcePacketSchema,
31 pub packet_id: NonEmptyString,
34 pub source_ref: Reference,
38 pub signal_type: NonEmptyString,
42 pub title: NonEmptyString,
43 pub observed_at: IsoDateTime,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub body_preview: Option<NonEmptyString>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub authenticity: Option<SignalAuthenticity>,
48 pub redaction_status: OperationalProposalRedactionStatus,
51 #[serde(default, skip_serializing_if = "JsonObject::is_empty")]
55 pub workflow_inputs: JsonObject,
56 #[serde(skip_serializing_if = "Option::is_none")]
59 pub adapter_payload: Option<JsonObject>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub fingerprint: Option<Fingerprint>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub related_refs: Vec<Reference>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub extensions: Option<JsonObject>,
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use crate::ReferenceType;
74
75 #[test]
76 fn source_packet_round_trips_minimal_shape() -> Result<(), serde_json::Error> {
77 let packet = SourcePacket {
78 schema: SourcePacketSchema::V1,
79 packet_id: "src_pkt_001".into(),
80 source_ref: Reference {
81 reference_type: ReferenceType::SlackThread,
82 uri: "slack://team/T1/channel/C1/thread/1700000000.0001".into(),
83 provider: Some("slack".into()),
84 locator: Some("team/C1/1700000000.0001".into()),
85 label: None,
86 observed_at: None,
87 proof_kind: None,
88 },
89 signal_type: "chat_message".into(),
90 title: "Incoming customer message".into(),
91 observed_at: "2026-05-28T12:00:00Z".into(),
92 body_preview: Some("Customer reports delivery failure".into()),
93 authenticity: None,
94 redaction_status: OperationalProposalRedactionStatus::Redacted,
95 workflow_inputs: JsonObject::new(),
96 adapter_payload: None,
97 fingerprint: None,
98 related_refs: Vec::new(),
99 extensions: None,
100 };
101
102 let json = serde_json::to_value(&packet)?;
103 let round_tripped: SourcePacket = serde_json::from_value(json)?;
104 assert_eq!(packet, round_tripped);
105 Ok(())
106 }
107
108 #[test]
109 fn source_packet_rejects_unknown_top_level_field() {
110 let json = serde_json::json!({
111 "schema": "runx.source_packet.v1",
112 "packet_id": "src_pkt_001",
113 "source_ref": {
114 "type": "slack_thread",
115 "uri": "slack://team/T1/channel/C1/thread/1700000000.0001",
116 },
117 "signal_type": "chat_message",
118 "title": "Incoming customer message",
119 "observed_at": "2026-05-28T12:00:00Z",
120 "redaction_status": "redacted",
121 "stray_field": "nope",
122 });
123 let parsed: Result<SourcePacket, _> = serde_json::from_value(json);
124 assert!(
125 parsed.is_err(),
126 "deny_unknown_fields should reject unknown top-level field"
127 );
128 }
129}