Skip to main content

a3s_code_core/research/
event.rs

1use super::{digest, validate_digest_field, validate_id, ResearchContractError};
2use crate::core_identity::CoreEventIdentity;
3use serde::{Deserialize, Serialize};
4
5pub const RESEARCH_EVENT_SCHEMA_V1: &str = "a3s.code.science-event.v1";
6const RESEARCH_EVENT_DIGEST_DOMAIN: &str = "a3s.code.science-event.identity.v1";
7pub const RESEARCH_MAX_EVENT_TYPE_BYTES: usize = 128;
8
9/// Digest-only research event projection for Desktop and other hosts.
10#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12pub struct ResearchEventV1 {
13    pub schema: String,
14    pub project_id: String,
15    pub project_revision: u64,
16    pub run_id: Option<String>,
17    pub sequence: u64,
18    pub event_type: String,
19    pub payload_digest: String,
20    pub observed_at_ms: u64,
21    pub event_digest: String,
22}
23
24impl ResearchEventV1 {
25    pub fn new(
26        project_id: impl Into<String>,
27        project_revision: u64,
28        run_id: Option<String>,
29        sequence: u64,
30        event_type: impl Into<String>,
31        payload_digest: impl Into<String>,
32        observed_at_ms: u64,
33    ) -> Result<Self, ResearchContractError> {
34        let mut event = Self {
35            schema: RESEARCH_EVENT_SCHEMA_V1.to_owned(),
36            project_id: project_id.into(),
37            project_revision,
38            run_id,
39            sequence,
40            event_type: event_type.into(),
41            payload_digest: payload_digest.into(),
42            observed_at_ms,
43            event_digest: String::new(),
44        };
45        event.validate_without_digest()?;
46        event.event_digest = event.expected_digest()?;
47        Ok(event)
48    }
49
50    pub fn validate(&self) -> Result<(), ResearchContractError> {
51        self.validate_without_digest()?;
52        validate_digest_field("eventDigest", &self.event_digest)?;
53        if self.event_digest != self.expected_digest()? {
54            return Err(ResearchContractError::DigestMismatch("eventDigest"));
55        }
56        Ok(())
57    }
58
59    /// Decode a bounded JSON research event and validate its identity before
60    /// returning it to a caller at a process boundary.
61    pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
62        let event: Self = super::decode_json_slice(bytes)?;
63        event.validate()?;
64        Ok(event)
65    }
66
67    /// Encode a validated research event for a process boundary.
68    pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
69        self.validate()?;
70        super::encode_json(self)
71    }
72
73    /// Project one Core event into the research event view.
74    ///
75    /// Core evidence cursors are zero-based because they align with retained
76    /// `RunEventRecord` sequences. The research wire contract is intentionally
77    /// one-based, so this adapter performs the only explicit representation
78    /// conversion while preserving the operation id, payload digest, and
79    /// observation time. Research event names are dotted by contract, so the
80    /// runtime name is placed under the explicit `code` namespace and runtime
81    /// underscores are normalized to the research contract's hyphens.
82    pub fn from_core_event(
83        project_id: impl Into<String>,
84        project_revision: u64,
85        event: &CoreEventIdentity,
86    ) -> Result<Self, ResearchContractError> {
87        Self::from_core_event_for_run(
88            project_id,
89            project_revision,
90            event.identity.operation_id.as_str(),
91            event,
92        )
93    }
94
95    /// Project a Core event while supplying the actual Code Run identity.
96    ///
97    /// `CoreEventIdentity::operation_id` is intentionally opaque and may
98    /// represent a session-scoped operation rather than the bare Run id.
99    /// Research projections must therefore use this explicit adapter whenever
100    /// a host has the owning `ExecutionTargetV1` available.
101    pub fn from_core_event_for_run(
102        project_id: impl Into<String>,
103        project_revision: u64,
104        run_id: impl Into<String>,
105        event: &CoreEventIdentity,
106    ) -> Result<Self, ResearchContractError> {
107        event
108            .validate()
109            .map_err(|_| ResearchContractError::InvalidField("coreEvent"))?;
110        let sequence = event
111            .identity
112            .evidence_cursor
113            .sequence()
114            .checked_add(1)
115            .ok_or(ResearchContractError::InvalidField("sequence"))?;
116        Self::new(
117            project_id,
118            project_revision,
119            Some(run_id.into()),
120            sequence,
121            format!("code.{}", event.event_type.replace('_', "-")),
122            event.payload_digest.clone(),
123            event.observed_at_ms,
124        )
125    }
126
127    fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
128        if self.schema != RESEARCH_EVENT_SCHEMA_V1 {
129            return Err(ResearchContractError::UnsupportedSchema);
130        }
131        validate_id("projectId", &self.project_id)?;
132        if self.project_revision == 0 {
133            return Err(ResearchContractError::InvalidField("projectRevision"));
134        }
135        if let Some(run_id) = &self.run_id {
136            validate_id("runId", run_id)?;
137        }
138        if self.sequence == 0 {
139            return Err(ResearchContractError::InvalidField("sequence"));
140        }
141        validate_event_type(&self.event_type)?;
142        validate_digest_field("payloadDigest", &self.payload_digest)?;
143        if self.observed_at_ms == 0 {
144            return Err(ResearchContractError::InvalidField("observedAtMs"));
145        }
146        Ok(())
147    }
148
149    fn expected_digest(&self) -> Result<String, ResearchContractError> {
150        #[derive(Serialize)]
151        struct Identity<'a> {
152            schema: &'a str,
153            project_id: &'a str,
154            project_revision: u64,
155            run_id: Option<&'a str>,
156            sequence: u64,
157            event_type: &'a str,
158            payload_digest: &'a str,
159            observed_at_ms: u64,
160        }
161        digest(
162            RESEARCH_EVENT_DIGEST_DOMAIN,
163            &Identity {
164                schema: &self.schema,
165                project_id: &self.project_id,
166                project_revision: self.project_revision,
167                run_id: self.run_id.as_deref(),
168                sequence: self.sequence,
169                event_type: &self.event_type,
170                payload_digest: &self.payload_digest,
171                observed_at_ms: self.observed_at_ms,
172            },
173        )
174    }
175}
176
177fn validate_event_type(value: &str) -> Result<(), ResearchContractError> {
178    if value.is_empty()
179        || value.len() > RESEARCH_MAX_EVENT_TYPE_BYTES
180        || value.starts_with('.')
181        || value.ends_with('.')
182        || value.split('.').any(|segment| {
183            segment.is_empty()
184                || !segment
185                    .bytes()
186                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
187        })
188    {
189        return Err(ResearchContractError::InvalidField("eventType"));
190    }
191    Ok(())
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::core_identity::{
198        CapabilityStamp, CoreIdentity, EvidenceCursor, OperationId, SourceRevision,
199    };
200    use crate::AgentEvent;
201
202    fn digest(ch: char) -> String {
203        format!("sha256:{}", ch.to_string().repeat(64))
204    }
205
206    #[test]
207    fn event_rejects_noncanonical_event_types_and_binds_payload() {
208        assert!(matches!(
209            ResearchEventV1::new("project-1", 1, None, 1, "Research.Started", digest('a'), 1),
210            Err(ResearchContractError::InvalidField("eventType"))
211        ));
212        let event = ResearchEventV1::new(
213            "project-1",
214            1,
215            Some("run-1".to_owned()),
216            1,
217            "research.run.admitted",
218            digest('a'),
219            1,
220        )
221        .unwrap();
222        assert!(event.validate().is_ok());
223        let encoded = event.to_vec().unwrap();
224        assert_eq!(ResearchEventV1::from_slice(&encoded).unwrap(), event);
225
226        let mut encoded = serde_json::to_value(&event).unwrap();
227        encoded["unexpected"] = serde_json::Value::Bool(true);
228        assert!(ResearchEventV1::from_slice(&serde_json::to_vec(&encoded).unwrap()).is_err());
229    }
230
231    #[test]
232    fn core_event_projection_preserves_identity_and_uses_research_sequence() {
233        let core = CoreEventIdentity::from_agent_event(
234            CoreIdentity::new(
235                OperationId::new("session-1/run-1").unwrap(),
236                SourceRevision::new(4),
237                Some(CapabilityStamp::new(2, digest('b')).unwrap()),
238                EvidenceCursor::new(6),
239            ),
240            42,
241            &AgentEvent::TextDelta {
242                text: "finding".to_owned(),
243            },
244        )
245        .unwrap();
246        let projected = ResearchEventV1::from_core_event("project-1", 3, &core).unwrap();
247
248        assert_eq!(projected.run_id.as_deref(), Some("session-1/run-1"));
249        assert_eq!(projected.sequence, 7);
250        assert_eq!(projected.event_type, "code.text-delta");
251        assert_eq!(projected.payload_digest, core.payload_digest);
252        assert_eq!(projected.observed_at_ms, 42);
253        assert!(projected.validate().is_ok());
254    }
255
256    #[test]
257    fn explicit_run_projection_does_not_confuse_operation_and_run_identity() {
258        let core = CoreEventIdentity::from_agent_event(
259            CoreIdentity::new(
260                OperationId::new("session-1/run-1/turn-2").unwrap(),
261                SourceRevision::new(4),
262                None,
263                EvidenceCursor::new(6),
264            ),
265            42,
266            &AgentEvent::TextDelta {
267                text: "finding".to_owned(),
268            },
269        )
270        .unwrap();
271        let projected =
272            ResearchEventV1::from_core_event_for_run("project-1", 3, "run-1", &core).unwrap();
273
274        assert_eq!(projected.run_id.as_deref(), Some("run-1"));
275        assert_ne!(projected.run_id.as_deref(), Some("session-1/run-1/turn-2"));
276        assert!(projected.validate().is_ok());
277    }
278}