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#[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 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 pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
69 self.validate()?;
70 super::encode_json(self)
71 }
72
73 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 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}