1extern crate alloc;
8use alloc::string::String;
9use alloc::vec::Vec;
10use core::fmt;
11
12use crate::{Header, Id128, SubstrateKind};
13
14#[derive(Clone, Debug)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub struct Event {
18 #[cfg_attr(feature = "serde", serde(flatten))]
19 pub header: Header,
20 pub verb: String,
22 pub substrate: SubstrateKind,
24 pub actor: Option<String>,
26 pub kind: EventKind,
28 pub payload: EventPayload,
30 pub payload_schema_version: u32,
32 pub profile_state_version: Option<u64>,
34 pub aggregate: Option<AggregateRef>,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
41pub enum EventOutcome {
42 #[default]
43 Success,
44 Denied,
45 Error,
46}
47
48impl EventOutcome {
49 pub const fn name(self) -> &'static str {
50 match self {
51 Self::Success => "success",
52 Self::Denied => "denied",
53 Self::Error => "error",
54 }
55 }
56}
57
58impl fmt::Display for EventOutcome {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 f.write_str(self.name())
61 }
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
67pub enum EventKind {
68 Audit,
69 RecallExecuted,
70 RerankExecuted,
71 SearchExecuted,
72 LinkCreated,
73 EntityCreated,
74 EntityUpdated,
75 EntityDeleted,
76 EntityMerged,
77 NoteCreated,
78 NoteUpdated,
79 NoteDeleted,
80 EdgeUpdated,
81 EdgeDeleted,
82 TaskTransitioned,
83 FeedbackExplicit,
84 ProfileResolutionRecommended,
85 ProfileMerged,
86 EmbeddingModelChanged,
87 EmbeddingMigrationCompleted,
88 EmbeddingMigrationFailed,
89 EmbeddingDriftDetected,
90 ProposalCreated,
91 ProposalReviewed,
92 ProposalApplied,
93 ProposalWithdrawn,
94}
95
96impl EventKind {
97 pub const ALL: [Self; 26] = [
98 Self::Audit,
99 Self::RecallExecuted,
100 Self::RerankExecuted,
101 Self::SearchExecuted,
102 Self::LinkCreated,
103 Self::EntityCreated,
104 Self::EntityUpdated,
105 Self::EntityDeleted,
106 Self::EntityMerged,
107 Self::NoteCreated,
108 Self::NoteUpdated,
109 Self::NoteDeleted,
110 Self::EdgeUpdated,
111 Self::EdgeDeleted,
112 Self::TaskTransitioned,
113 Self::FeedbackExplicit,
114 Self::ProfileResolutionRecommended,
115 Self::ProfileMerged,
116 Self::EmbeddingModelChanged,
117 Self::EmbeddingMigrationCompleted,
118 Self::EmbeddingMigrationFailed,
119 Self::EmbeddingDriftDetected,
120 Self::ProposalCreated,
121 Self::ProposalReviewed,
122 Self::ProposalApplied,
123 Self::ProposalWithdrawn,
124 ];
125
126 pub const fn name(self) -> &'static str {
127 match self {
128 Self::Audit => "audit",
129 Self::RecallExecuted => "recall_executed",
130 Self::RerankExecuted => "rerank_executed",
131 Self::SearchExecuted => "search_executed",
132 Self::LinkCreated => "link_created",
133 Self::EntityCreated => "entity_created",
134 Self::EntityUpdated => "entity_updated",
135 Self::EntityDeleted => "entity_deleted",
136 Self::EntityMerged => "entity_merged",
137 Self::NoteCreated => "note_created",
138 Self::NoteUpdated => "note_updated",
139 Self::NoteDeleted => "note_deleted",
140 Self::EdgeUpdated => "edge_updated",
141 Self::EdgeDeleted => "edge_deleted",
142 Self::TaskTransitioned => "task_transitioned",
143 Self::FeedbackExplicit => "feedback_explicit",
144 Self::ProfileResolutionRecommended => "profile_resolution_recommended",
145 Self::ProfileMerged => "profile_merged",
146 Self::EmbeddingModelChanged => "embedding_model_changed",
147 Self::EmbeddingMigrationCompleted => "embedding_migration_completed",
148 Self::EmbeddingMigrationFailed => "embedding_migration_failed",
149 Self::EmbeddingDriftDetected => "embedding_drift_detected",
150 Self::ProposalCreated => "proposal_created",
151 Self::ProposalReviewed => "proposal_reviewed",
152 Self::ProposalApplied => "proposal_applied",
153 Self::ProposalWithdrawn => "proposal_withdrawn",
154 }
155 }
156}
157
158impl fmt::Display for EventKind {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.write_str(self.name())
161 }
162}
163
164const EVENT_KIND_VALID: &[&str] = &[
165 "audit",
166 "recall_executed",
167 "rerank_executed",
168 "search_executed",
169 "link_created",
170 "entity_created",
171 "entity_updated",
172 "entity_deleted",
173 "entity_merged",
174 "note_created",
175 "note_updated",
176 "note_deleted",
177 "edge_updated",
178 "edge_deleted",
179 "task_transitioned",
180 "feedback_explicit",
181 "profile_resolution_recommended",
182 "profile_merged",
183 "embedding_model_changed",
184 "embedding_migration_completed",
185 "embedding_migration_failed",
186 "embedding_drift_detected",
187 "proposal_created",
188 "proposal_reviewed",
189 "proposal_applied",
190 "proposal_withdrawn",
191];
192
193impl core::str::FromStr for EventKind {
194 type Err = crate::error::UnknownVariant;
195
196 fn from_str(s: &str) -> Result<Self, Self::Err> {
197 match s.trim().to_ascii_lowercase().as_str() {
198 "audit" => Ok(Self::Audit),
199 "recall_executed" => Ok(Self::RecallExecuted),
200 "rerank_executed" => Ok(Self::RerankExecuted),
201 "search_executed" => Ok(Self::SearchExecuted),
202 "link_created" => Ok(Self::LinkCreated),
203 "entity_created" => Ok(Self::EntityCreated),
204 "entity_updated" => Ok(Self::EntityUpdated),
205 "entity_deleted" => Ok(Self::EntityDeleted),
206 "entity_merged" => Ok(Self::EntityMerged),
207 "note_created" => Ok(Self::NoteCreated),
208 "note_updated" => Ok(Self::NoteUpdated),
209 "note_deleted" => Ok(Self::NoteDeleted),
210 "edge_updated" => Ok(Self::EdgeUpdated),
211 "edge_deleted" => Ok(Self::EdgeDeleted),
212 "task_transitioned" => Ok(Self::TaskTransitioned),
213 "feedback_explicit" => Ok(Self::FeedbackExplicit),
214 "profile_resolution_recommended" => Ok(Self::ProfileResolutionRecommended),
215 "profile_merged" => Ok(Self::ProfileMerged),
216 "embedding_model_changed" => Ok(Self::EmbeddingModelChanged),
217 "embedding_migration_completed" => Ok(Self::EmbeddingMigrationCompleted),
218 "embedding_migration_failed" => Ok(Self::EmbeddingMigrationFailed),
219 "embedding_drift_detected" => Ok(Self::EmbeddingDriftDetected),
220 "proposal_created" => Ok(Self::ProposalCreated),
221 "proposal_reviewed" => Ok(Self::ProposalReviewed),
222 "proposal_applied" => Ok(Self::ProposalApplied),
223 "proposal_withdrawn" => Ok(Self::ProposalWithdrawn),
224 other => Err(crate::error::UnknownVariant::new(
225 "event_kind",
226 other,
227 EVENT_KIND_VALID,
228 )),
229 }
230 }
231}
232
233#[derive(Clone, Debug, PartialEq, Eq)]
234#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
235pub struct AggregateRef {
236 pub kind: String,
237 pub id: Id128,
238}
239
240#[derive(Clone, Debug, PartialEq)]
241#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
242#[cfg_attr(
243 feature = "serde",
244 serde(tag = "kind", content = "payload", rename_all = "snake_case")
245)]
246pub enum EventPayload {
247 Json(String),
248 RerankExecuted(RerankExecutedPayload),
249 ProposalCreated(ProposalCreatedPayload),
250 ProposalReviewed(ProposalReviewedPayload),
251 ProposalApplied(ProposalAppliedPayload),
252 ProposalWithdrawn(ProposalWithdrawnPayload),
253}
254
255impl Default for EventPayload {
256 fn default() -> Self {
257 Self::Json("{}".into())
258 }
259}
260
261#[derive(Clone, Debug, PartialEq)]
262#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
263pub struct RerankExecutedPayload {
264 pub served_by_profile_id: Option<String>,
265 pub model_id: Id128,
266 pub candidates: Vec<Id128>,
267 pub reranked: Vec<(Id128, Vec<(String, f32)>)>,
268 pub final_scores: Vec<(Id128, f32)>,
269 pub latency_us: u64,
270 pub hook_applied: bool,
271 pub hook_target_match: bool,
272}
273
274#[derive(Clone, Debug, PartialEq)]
275#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
276pub struct ProposalCreatedPayload {
277 pub proposal_id: Id128,
278 pub proposer: String,
279 pub title: String,
280 pub description: String,
281 pub changeset: ProposalChangeset,
282 pub reviewers: Vec<String>,
283 pub expiry: Option<crate::Timestamp>,
284 pub parent_id: Option<Id128>,
285}
286
287#[derive(Clone, Debug, PartialEq)]
288#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
289#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
290pub enum ProposalChangeset {
291 AddEntity {
292 entity: String,
293 },
294 UpdateEntity {
295 id: Id128,
296 patch: String,
297 },
298 AddEdge {
299 source: Id128,
300 target: Id128,
301 relation: crate::EdgeRelation,
302 weight: Option<f32>,
303 },
304 AddNote {
305 note: String,
306 },
307 MergeEntities {
308 into: Id128,
309 from: Id128,
310 },
311 SupersedeEntity {
312 old: Id128,
313 new: Id128,
314 },
315 Compound {
316 steps: Vec<ProposalChangeset>,
317 },
318}
319
320#[derive(Clone, Debug, PartialEq)]
321#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
322pub struct ProposalReviewedPayload {
323 pub proposal_id: Id128,
324 pub reviewer: String,
325 pub decision: ProposalDecision,
326 pub comment: Option<String>,
327}
328
329#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
330#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
331#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
332pub enum ProposalDecision {
333 Approve,
334 Reject,
335 Comment,
336 RequestChanges,
337}
338
339#[derive(Clone, Debug, PartialEq)]
340#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
341pub struct ProposalAppliedPayload {
342 pub proposal_id: Id128,
343 pub applied_at: crate::Timestamp,
344 pub applied_by: String,
345 pub result: ApplyResult,
346}
347
348#[derive(Clone, Debug, PartialEq)]
349#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
350#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
351pub enum ApplyResult {
352 Success {
353 created_records: Vec<Id128>,
354 },
355 Failed {
356 error: String,
357 applied_step_count: u32,
358 },
359}
360
361#[derive(Clone, Debug, PartialEq)]
362#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
363pub struct ProposalWithdrawnPayload {
364 pub proposal_id: Id128,
365 pub by: String,
366 pub reason: Option<String>,
367}
368
369pub struct EventBuilder {
371 verb: String,
372 substrate: SubstrateKind,
373 actor: Option<String>,
374 kind: EventKind,
375 payload: EventPayload,
376 payload_schema_version: u32,
377 profile_state_version: Option<u64>,
378 aggregate: Option<AggregateRef>,
379}
380
381impl EventBuilder {
382 pub fn new(
383 verb: impl Into<String>,
384 substrate: SubstrateKind,
385 actor: impl Into<String>,
386 ) -> Self {
387 Self {
388 verb: verb.into(),
389 substrate,
390 actor: Some(actor.into()),
391 kind: EventKind::Audit,
392 payload: EventPayload::default(),
393 payload_schema_version: 1,
394 profile_state_version: None,
395 aggregate: None,
396 }
397 }
398
399 pub fn kind(mut self, kind: EventKind) -> Self {
400 self.kind = kind;
401 self
402 }
403
404 pub fn payload(mut self, payload: EventPayload) -> Self {
405 self.payload = payload;
406 self
407 }
408
409 pub fn payload_schema_version(mut self, version: u32) -> Self {
410 self.payload_schema_version = version;
411 self
412 }
413
414 pub fn profile_state_version(mut self, version: u64) -> Self {
415 self.profile_state_version = Some(version);
416 self
417 }
418
419 pub fn aggregate(mut self, aggregate: AggregateRef) -> Self {
420 self.aggregate = Some(aggregate);
421 self
422 }
423
424 pub fn build(self, header: Header) -> Event {
425 Event {
426 header,
427 verb: self.verb,
428 substrate: self.substrate,
429 actor: self.actor,
430 kind: self.kind,
431 payload: self.payload,
432 payload_schema_version: self.payload_schema_version,
433 profile_state_version: self.profile_state_version,
434 aggregate: self.aggregate,
435 }
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 extern crate alloc;
442
443 use super::*;
444 use crate::{Namespace, Timestamp};
445
446 fn header() -> Header {
447 Header::new(
448 Id128::from_u128(1),
449 Namespace::local(),
450 Timestamp::from_secs(1700000000),
451 )
452 }
453
454 #[test]
455 fn event_kind_parse_roundtrip() {
456 for kind in EventKind::ALL {
457 let parsed: EventKind = kind
458 .name()
459 .parse()
460 .expect("EventKind::name must parse back");
461 assert_eq!(parsed, kind);
462 }
463 }
464
465 #[test]
466 fn rerank_payload_records_served_profile() {
467 let payload = EventPayload::RerankExecuted(RerankExecutedPayload {
468 served_by_profile_id: Some("profile-a".into()),
469 model_id: Id128::from_u128(1),
470 candidates: Vec::new(),
471 reranked: Vec::new(),
472 final_scores: Vec::new(),
473 latency_us: 100,
474 hook_applied: false,
475 hook_target_match: false,
476 });
477 let event = EventBuilder::new("rerank", SubstrateKind::Note, "agent:test")
478 .kind(EventKind::RerankExecuted)
479 .payload(payload)
480 .build(header());
481
482 if let EventPayload::RerankExecuted(ref p) = event.payload {
483 assert_eq!(p.served_by_profile_id.as_deref(), Some("profile-a"));
484 } else {
485 panic!("unexpected payload variant");
486 }
487 }
488
489 #[test]
490 fn proposal_payloads_are_typed() {
491 let payload = EventPayload::ProposalReviewed(ProposalReviewedPayload {
492 proposal_id: Id128::from_u128(42),
493 reviewer: "ocean".into(),
494 decision: ProposalDecision::Approve,
495 comment: None,
496 });
497 let event = EventBuilder::new("review", SubstrateKind::Entity, "ocean")
498 .kind(EventKind::ProposalReviewed)
499 .payload(payload)
500 .build(header());
501 assert_eq!(event.kind.name(), "proposal_reviewed");
502 }
503}