Skip to main content

runx_contracts/
source_packet.rs

1//! Source packet contract: normalized, redacted intake from any source loader.
2//!
3//! `SourcePacket` is the canonical wire shape every source adapter produces
4//! before handing off to triage / action skills. It carries provider-neutral
5//! identity (`source_ref`) and the typed workflow slots that downstream skills
6//! consume, plus optional raw `adapter_payload` for replay and audit.
7//!
8//! Provider names and locators live inside the central refs, not top-level
9//! fields, so the same packet can be rendered through Slack, GitHub, Teams,
10//! Linear, Jira, email, or any other channel without contract changes.
11
12use 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    /// Stable identifier for this packet. Adapters typically derive this from
32    /// `source_ref` plus a content hash so re-deliveries are idempotent.
33    pub packet_id: NonEmptyString,
34    /// Central runx reference identifying the source. Provider names and
35    /// locators live inside the reference; the packet itself stays
36    /// provider-neutral.
37    pub source_ref: Reference,
38    /// Open signal type identifier (e.g. `signal_type::ALERT`,
39    /// `signal_type::SUPPORT_TICKET`). Adapters can publish their own
40    /// identifier without a contract edit.
41    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    /// Redaction status applied to `body_preview`, `workflow_inputs`, and any
49    /// human-visible fields in `adapter_payload`.
50    pub redaction_status: OperationalProposalRedactionStatus,
51    /// Typed workflow slots that the downstream triage / action skill reads.
52    /// The contract carries them as opaque JSON; the consuming skill is
53    /// responsible for shape-validating its own slice.
54    #[serde(default, skip_serializing_if = "JsonObject::is_empty")]
55    pub workflow_inputs: JsonObject,
56    /// Raw adapter payload. Optional; held for replay and audit. Must already
57    /// have any redactions applied that `redaction_status` declares.
58    #[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    /// Related refs the adapter wants to surface alongside the source: thread,
63    /// parent issue, dedupe candidates, originating webhook delivery.
64    #[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}