1extern crate alloc;
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7
8use crate::{Header, Id128, SubstrateKind};
9
10#[derive(Clone, Debug)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct Event {
14 #[cfg_attr(feature = "serde", serde(flatten))]
15 pub header: Header,
16 pub verb: String,
18 pub substrate: SubstrateKind,
20 pub actor: Option<String>,
22 pub kind: EventKind,
24 pub payload: EventPayload,
26 pub payload_schema_version: u32,
28 pub profile_state_version: Option<u64>,
30 pub aggregate: Option<AggregateRef>,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
38pub enum EventOutcome {
39 #[default]
41 Success,
42 Denied,
44 Error,
46}
47
48impl EventOutcome {
49 pub const fn name(self) -> &'static str {
51 match self {
52 Self::Success => "success",
53 Self::Denied => "denied",
54 Self::Error => "error",
55 }
56 }
57}
58
59impl fmt::Display for EventOutcome {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.write_str(self.name())
62 }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
71pub enum EventKind {
72 Audit,
74 RecallExecuted,
76 RerankExecuted,
78 SearchExecuted,
80 LinkCreated,
82 EntityCreated,
84 EntityUpdated,
86 EntityDeleted,
88 EntityMerged,
90 NoteCreated,
92 NoteUpdated,
94 NoteDeleted,
96 EdgeUpdated,
98 EdgeDeleted,
100 TaskTransitioned,
102 FeedbackExplicit,
104 ProfileResolutionRecommended,
106 ProfileMerged,
108 EmbeddingModelChanged,
110 EmbeddingMigrationCompleted,
112 EmbeddingMigrationFailed,
114 EmbeddingDriftDetected,
116 ProposalCreated,
118 ProposalReviewed,
120 ProposalApplied,
122 ProposalWithdrawn,
124 ChannelPollStarted,
126 ChannelPollSucceeded,
128 ChannelPollFailed,
130 ChannelBackoffArmed,
132 ChannelBackoffReset,
134 ChannelHeartbeatPersistFailed,
136 ConfigLocked,
138 CheckpointOutcomeRecorded,
140 PhaseStarted,
142 PhaseCompleted,
144 PhaseCancelled,
146}
147
148impl EventKind {
149 pub const ALL: [Self; 37] = [
151 Self::Audit,
152 Self::RecallExecuted,
153 Self::RerankExecuted,
154 Self::SearchExecuted,
155 Self::LinkCreated,
156 Self::EntityCreated,
157 Self::EntityUpdated,
158 Self::EntityDeleted,
159 Self::EntityMerged,
160 Self::NoteCreated,
161 Self::NoteUpdated,
162 Self::NoteDeleted,
163 Self::EdgeUpdated,
164 Self::EdgeDeleted,
165 Self::TaskTransitioned,
166 Self::FeedbackExplicit,
167 Self::ProfileResolutionRecommended,
168 Self::ProfileMerged,
169 Self::EmbeddingModelChanged,
170 Self::EmbeddingMigrationCompleted,
171 Self::EmbeddingMigrationFailed,
172 Self::EmbeddingDriftDetected,
173 Self::ProposalCreated,
174 Self::ProposalReviewed,
175 Self::ProposalApplied,
176 Self::ProposalWithdrawn,
177 Self::ChannelPollStarted,
178 Self::ChannelPollSucceeded,
179 Self::ChannelPollFailed,
180 Self::ChannelBackoffArmed,
181 Self::ChannelBackoffReset,
182 Self::ChannelHeartbeatPersistFailed,
183 Self::ConfigLocked,
184 Self::CheckpointOutcomeRecorded,
185 Self::PhaseStarted,
186 Self::PhaseCompleted,
187 Self::PhaseCancelled,
188 ];
189
190 pub const fn name(self) -> &'static str {
192 match self {
193 Self::Audit => "audit",
194 Self::RecallExecuted => "recall_executed",
195 Self::RerankExecuted => "rerank_executed",
196 Self::SearchExecuted => "search_executed",
197 Self::LinkCreated => "link_created",
198 Self::EntityCreated => "entity_created",
199 Self::EntityUpdated => "entity_updated",
200 Self::EntityDeleted => "entity_deleted",
201 Self::EntityMerged => "entity_merged",
202 Self::NoteCreated => "note_created",
203 Self::NoteUpdated => "note_updated",
204 Self::NoteDeleted => "note_deleted",
205 Self::EdgeUpdated => "edge_updated",
206 Self::EdgeDeleted => "edge_deleted",
207 Self::TaskTransitioned => "task_transitioned",
208 Self::FeedbackExplicit => "feedback_explicit",
209 Self::ProfileResolutionRecommended => "profile_resolution_recommended",
210 Self::ProfileMerged => "profile_merged",
211 Self::EmbeddingModelChanged => "embedding_model_changed",
212 Self::EmbeddingMigrationCompleted => "embedding_migration_completed",
213 Self::EmbeddingMigrationFailed => "embedding_migration_failed",
214 Self::EmbeddingDriftDetected => "embedding_drift_detected",
215 Self::ProposalCreated => "proposal_created",
216 Self::ProposalReviewed => "proposal_reviewed",
217 Self::ProposalApplied => "proposal_applied",
218 Self::ProposalWithdrawn => "proposal_withdrawn",
219 Self::ChannelPollStarted => "channel_poll_started",
220 Self::ChannelPollSucceeded => "channel_poll_succeeded",
221 Self::ChannelPollFailed => "channel_poll_failed",
222 Self::ChannelBackoffArmed => "channel_backoff_armed",
223 Self::ChannelBackoffReset => "channel_backoff_reset",
224 Self::ChannelHeartbeatPersistFailed => "channel_heartbeat_persist_failed",
225 Self::ConfigLocked => "config_locked",
226 Self::CheckpointOutcomeRecorded => "checkpoint_outcome_recorded",
227 Self::PhaseStarted => "phase_started",
228 Self::PhaseCompleted => "phase_completed",
229 Self::PhaseCancelled => "phase_cancelled",
230 }
231 }
232}
233
234impl fmt::Display for EventKind {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 f.write_str(self.name())
237 }
238}
239
240const EVENT_KIND_VALID: &[&str] = &[
241 "audit",
242 "recall_executed",
243 "rerank_executed",
244 "search_executed",
245 "link_created",
246 "entity_created",
247 "entity_updated",
248 "entity_deleted",
249 "entity_merged",
250 "note_created",
251 "note_updated",
252 "note_deleted",
253 "edge_updated",
254 "edge_deleted",
255 "task_transitioned",
256 "feedback_explicit",
257 "profile_resolution_recommended",
258 "profile_merged",
259 "embedding_model_changed",
260 "embedding_migration_completed",
261 "embedding_migration_failed",
262 "embedding_drift_detected",
263 "proposal_created",
264 "proposal_reviewed",
265 "proposal_applied",
266 "proposal_withdrawn",
267 "channel_poll_started",
268 "channel_poll_succeeded",
269 "channel_poll_failed",
270 "channel_backoff_armed",
271 "channel_backoff_reset",
272 "channel_heartbeat_persist_failed",
273 "config_locked",
274 "checkpoint_outcome_recorded",
275 "phase_started",
276 "phase_completed",
277 "phase_cancelled",
278];
279
280impl core::str::FromStr for EventKind {
281 type Err = crate::error::UnknownVariant;
282
283 fn from_str(s: &str) -> Result<Self, Self::Err> {
284 match s.trim().to_ascii_lowercase().as_str() {
285 "audit" => Ok(Self::Audit),
286 "recall_executed" => Ok(Self::RecallExecuted),
287 "rerank_executed" => Ok(Self::RerankExecuted),
288 "search_executed" => Ok(Self::SearchExecuted),
289 "link_created" => Ok(Self::LinkCreated),
290 "entity_created" => Ok(Self::EntityCreated),
291 "entity_updated" => Ok(Self::EntityUpdated),
292 "entity_deleted" => Ok(Self::EntityDeleted),
293 "entity_merged" => Ok(Self::EntityMerged),
294 "note_created" => Ok(Self::NoteCreated),
295 "note_updated" => Ok(Self::NoteUpdated),
296 "note_deleted" => Ok(Self::NoteDeleted),
297 "edge_updated" => Ok(Self::EdgeUpdated),
298 "edge_deleted" => Ok(Self::EdgeDeleted),
299 "task_transitioned" => Ok(Self::TaskTransitioned),
300 "feedback_explicit" => Ok(Self::FeedbackExplicit),
301 "profile_resolution_recommended" => Ok(Self::ProfileResolutionRecommended),
302 "profile_merged" => Ok(Self::ProfileMerged),
303 "embedding_model_changed" => Ok(Self::EmbeddingModelChanged),
304 "embedding_migration_completed" => Ok(Self::EmbeddingMigrationCompleted),
305 "embedding_migration_failed" => Ok(Self::EmbeddingMigrationFailed),
306 "embedding_drift_detected" => Ok(Self::EmbeddingDriftDetected),
307 "proposal_created" => Ok(Self::ProposalCreated),
308 "proposal_reviewed" => Ok(Self::ProposalReviewed),
309 "proposal_applied" => Ok(Self::ProposalApplied),
310 "proposal_withdrawn" => Ok(Self::ProposalWithdrawn),
311 "channel_poll_started" => Ok(Self::ChannelPollStarted),
312 "channel_poll_succeeded" => Ok(Self::ChannelPollSucceeded),
313 "channel_poll_failed" => Ok(Self::ChannelPollFailed),
314 "channel_backoff_armed" => Ok(Self::ChannelBackoffArmed),
315 "channel_backoff_reset" => Ok(Self::ChannelBackoffReset),
316 "channel_heartbeat_persist_failed" => Ok(Self::ChannelHeartbeatPersistFailed),
317 "config_locked" => Ok(Self::ConfigLocked),
318 "checkpoint_outcome_recorded" => Ok(Self::CheckpointOutcomeRecorded),
319 "phase_started" => Ok(Self::PhaseStarted),
320 "phase_completed" => Ok(Self::PhaseCompleted),
321 "phase_cancelled" => Ok(Self::PhaseCancelled),
322 other => Err(crate::error::UnknownVariant::new(
323 "event_kind",
324 other,
325 EVENT_KIND_VALID,
326 )),
327 }
328 }
329}
330
331#[derive(Clone, Debug, PartialEq, Eq)]
336#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
337pub struct AggregateRef {
338 pub kind: String,
340 pub id: Id128,
342}
343
344#[derive(Clone, Debug, PartialEq)]
350#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
351#[cfg_attr(
352 feature = "serde",
353 serde(tag = "kind", content = "payload", rename_all = "snake_case")
354)]
355pub enum EventPayload {
356 Json(String),
358 RerankExecuted(RerankExecutedPayload),
360 #[cfg(feature = "serde")]
362 ProposalCreated(ProposalCreatedPayload),
363 ProposalReviewed(ProposalReviewedPayload),
365 ProposalApplied(ProposalAppliedPayload),
367 ProposalWithdrawn(ProposalWithdrawnPayload),
369}
370
371impl Default for EventPayload {
372 fn default() -> Self {
373 Self::Json("{}".into())
374 }
375}
376
377#[derive(Clone, Debug, PartialEq)]
382#[cfg_attr(feature = "serde", derive(serde::Serialize))]
383pub struct RerankExecutedPayload {
384 pub served_by_profile_id: Option<String>,
386 pub model_id: Id128,
388 pub candidates: Vec<Id128>,
390 pub reranked: Vec<(Id128, Vec<(String, f32)>)>,
392 pub final_scores: Vec<(Id128, f32)>,
394 pub latency_us: u64,
396 pub hook_applied: bool,
398 pub hook_target_match: bool,
400}
401
402impl RerankExecutedPayload {
403 pub fn is_valid(&self) -> bool {
405 let reranked_ok = self
406 .reranked
407 .iter()
408 .all(|(_, scores)| scores.iter().all(|(_, s)| s.is_finite()));
409 let final_ok = self.final_scores.iter().all(|(_, s)| s.is_finite());
410 reranked_ok && final_ok
411 }
412}
413
414#[cfg(feature = "serde")]
415impl<'de> serde::Deserialize<'de> for RerankExecutedPayload {
416 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
417 where
418 D: serde::Deserializer<'de>,
419 {
420 #[derive(serde::Deserialize)]
421 struct Raw {
422 served_by_profile_id: Option<String>,
423 model_id: Id128,
424 candidates: Vec<Id128>,
425 reranked: Vec<(Id128, Vec<(String, f32)>)>,
426 final_scores: Vec<(Id128, f32)>,
427 latency_us: u64,
428 hook_applied: bool,
429 hook_target_match: bool,
430 }
431
432 let raw = Raw::deserialize(deserializer)?;
433
434 for (_, score) in &raw.final_scores {
435 if !score.is_finite() {
436 return Err(serde::de::Error::custom(alloc::format!(
437 "RerankExecutedPayload final_scores must be finite, got {score}"
438 )));
439 }
440 }
441 for (_, sections) in &raw.reranked {
442 for (section_name, score) in sections {
443 if !score.is_finite() {
444 return Err(serde::de::Error::custom(alloc::format!(
445 "RerankExecutedPayload reranked section '{section_name}' score must be finite, got {score}"
446 )));
447 }
448 }
449 }
450
451 Ok(RerankExecutedPayload {
452 served_by_profile_id: raw.served_by_profile_id,
453 model_id: raw.model_id,
454 candidates: raw.candidates,
455 reranked: raw.reranked,
456 final_scores: raw.final_scores,
457 latency_us: raw.latency_us,
458 hook_applied: raw.hook_applied,
459 hook_target_match: raw.hook_target_match,
460 })
461 }
462}
463
464#[cfg(feature = "serde")]
466#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
467pub struct ProposalCreatedPayload {
468 pub proposal_id: Id128,
469 pub proposer: String,
470 pub title: String,
471 pub description: String,
472 pub changeset: ProposalChangeset,
473 pub reviewers: Vec<String>,
474 pub expiry: Option<crate::Timestamp>,
475 pub parent_id: Option<Id128>,
476}
477
478#[cfg(feature = "serde")]
483#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
484pub struct EntityDraft {
485 pub kind: String,
487 pub name: String,
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub description: Option<String>,
492 #[serde(skip_serializing_if = "Option::is_none")]
494 pub properties: Option<serde_json::Value>,
495 #[serde(default, skip_serializing_if = "Vec::is_empty")]
497 pub tags: Vec<String>,
498}
499
500#[cfg(feature = "serde")]
504#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
505pub struct ProposalEntityPatch {
506 #[serde(skip_serializing_if = "Option::is_none")]
507 pub name: Option<String>,
508 #[serde(
510 default,
511 skip_serializing_if = "Option::is_none",
512 with = "serde_opt_opt"
513 )]
514 pub description: Option<Option<String>>,
515 #[serde(skip_serializing_if = "Option::is_none")]
516 pub properties: Option<serde_json::Value>,
517 #[serde(skip_serializing_if = "Option::is_none")]
518 pub tags: Option<Vec<String>>,
519}
520
521#[cfg(feature = "serde")]
525#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
526pub struct NoteDraft {
527 pub kind: String,
529 pub content: String,
531 #[serde(skip_serializing_if = "Option::is_none")]
533 pub name: Option<String>,
534 #[serde(skip_serializing_if = "Option::is_none")]
536 pub properties: Option<serde_json::Value>,
537}
538
539#[cfg(feature = "serde")]
541mod serde_opt_opt {
542 use serde::{Deserialize, Deserializer, Serialize, Serializer};
543
544 pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
545 where
546 T: Serialize,
547 S: Serializer,
548 {
549 match val {
550 None => unreachable!("skip_serializing_if guards the None case"),
551 Some(inner) => inner.serialize(s),
552 }
553 }
554
555 pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
556 where
557 T: Deserialize<'de>,
558 D: Deserializer<'de>,
559 {
560 let opt: Option<T> = Option::deserialize(d)?;
561 Ok(Some(opt))
562 }
563}
564
565#[cfg(feature = "serde")]
567#[derive(Clone, Debug, PartialEq, serde::Serialize)]
568#[serde(tag = "kind", rename_all = "snake_case")]
569pub enum ProposalChangeset {
570 AddEntity {
572 entity: EntityDraft,
573 },
574 UpdateEntity {
576 id: Id128,
577 patch: ProposalEntityPatch,
578 },
579 AddEdge {
581 source: Id128,
582 target: Id128,
583 relation: crate::EdgeRelation,
584 weight: Option<f32>,
585 },
586 AddNote {
588 note: NoteDraft,
589 },
590 MergeEntities {
591 into: Id128,
592 from: Id128,
593 },
594 SupersedeEntity {
595 old: Id128,
596 new: Id128,
597 },
598 Compound {
599 steps: Vec<ProposalChangeset>,
600 },
601}
602
603#[cfg(feature = "serde")]
604impl ProposalChangeset {
605 fn validate(&self) -> Result<(), alloc::string::String> {
606 match self {
607 Self::AddEdge { weight, .. } => {
608 if let Some(w) = weight {
609 if !w.is_finite() {
610 return Err(alloc::format!(
611 "ProposalChangeset AddEdge weight must be finite, got {w}"
612 ));
613 }
614 if !(*w >= 0.0 && *w <= 1.0) {
615 return Err(alloc::format!(
616 "ProposalChangeset AddEdge weight must be in [0.0, 1.0], got {w}"
617 ));
618 }
619 }
620 Ok(())
621 }
622 Self::Compound { steps } => {
623 for step in steps {
624 step.validate()?;
625 }
626 Ok(())
627 }
628 _ => Ok(()),
629 }
630 }
631}
632
633#[cfg(feature = "serde")]
634impl<'de> serde::Deserialize<'de> for ProposalChangeset {
635 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
636 where
637 D: serde::Deserializer<'de>,
638 {
639 #[derive(serde::Deserialize)]
640 #[serde(tag = "kind", rename_all = "snake_case")]
641 enum ProposalChangesetRaw {
642 AddEntity {
643 entity: EntityDraft,
644 },
645 UpdateEntity {
646 id: Id128,
647 patch: ProposalEntityPatch,
648 },
649 AddEdge {
650 source: Id128,
651 target: Id128,
652 relation: crate::EdgeRelation,
653 weight: Option<f32>,
654 },
655 AddNote {
656 note: NoteDraft,
657 },
658 MergeEntities {
659 into: Id128,
660 from: Id128,
661 },
662 SupersedeEntity {
663 old: Id128,
664 new: Id128,
665 },
666 Compound {
667 steps: Vec<ProposalChangeset>,
668 },
669 }
670
671 let raw = ProposalChangesetRaw::deserialize(deserializer)?;
672 let cs = match raw {
673 ProposalChangesetRaw::AddEntity { entity } => Self::AddEntity { entity },
674 ProposalChangesetRaw::UpdateEntity { id, patch } => Self::UpdateEntity { id, patch },
675 ProposalChangesetRaw::AddEdge {
676 source,
677 target,
678 relation,
679 weight,
680 } => Self::AddEdge {
681 source,
682 target,
683 relation,
684 weight,
685 },
686 ProposalChangesetRaw::AddNote { note } => Self::AddNote { note },
687 ProposalChangesetRaw::MergeEntities { into, from } => {
688 Self::MergeEntities { into, from }
689 }
690 ProposalChangesetRaw::SupersedeEntity { old, new } => {
691 Self::SupersedeEntity { old, new }
692 }
693 ProposalChangesetRaw::Compound { steps } => Self::Compound { steps },
694 };
695 cs.validate().map_err(serde::de::Error::custom)?;
696 Ok(cs)
697 }
698}
699
700#[cfg(not(feature = "serde"))]
701#[derive(Clone, Debug, PartialEq)]
702pub enum ProposalChangeset {
703 AddEdge {
704 source: Id128,
705 target: Id128,
706 relation: crate::EdgeRelation,
707 weight: Option<f32>,
708 },
709 MergeEntities {
710 into: Id128,
711 from: Id128,
712 },
713 SupersedeEntity {
714 old: Id128,
715 new: Id128,
716 },
717 Compound {
718 steps: Vec<ProposalChangeset>,
719 },
720}
721
722#[derive(Clone, Debug, PartialEq)]
724#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
725pub struct ProposalReviewedPayload {
726 pub proposal_id: Id128,
727 pub reviewer: String,
728 pub decision: ProposalDecision,
729 pub comment: Option<String>,
730}
731
732#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
734#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
735#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
736pub enum ProposalDecision {
737 Approve,
739 Reject,
741 Comment,
743 RequestChanges,
745}
746
747impl ProposalDecision {
748 pub fn as_str(self) -> &'static str {
753 match self {
754 Self::Approve => "approve",
755 Self::Reject => "reject",
756 Self::Comment => "comment",
757 Self::RequestChanges => "request_changes",
758 }
759 }
760}
761
762#[derive(Clone, Debug, PartialEq)]
764#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
765pub struct ProposalAppliedPayload {
766 pub proposal_id: Id128,
767 pub applied_at: crate::Timestamp,
768 pub applied_by: String,
769 pub result: ApplyResult,
770}
771
772#[derive(Clone, Debug, PartialEq)]
774#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
775#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
776pub enum ApplyResult {
777 Success {
778 created_records: Vec<Id128>,
779 },
780 Failed {
781 error: String,
782 applied_step_count: u32,
783 },
784}
785
786#[derive(Clone, Debug, PartialEq)]
788#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
789pub struct ProposalWithdrawnPayload {
790 pub proposal_id: Id128,
791 pub by: String,
792 pub reason: Option<String>,
793}
794
795pub struct EventBuilder {
797 verb: String,
798 substrate: SubstrateKind,
799 actor: Option<String>,
800 kind: EventKind,
801 payload: EventPayload,
802 payload_schema_version: u32,
803 profile_state_version: Option<u64>,
804 aggregate: Option<AggregateRef>,
805}
806
807impl EventBuilder {
808 pub fn new(
810 verb: impl Into<String>,
811 substrate: SubstrateKind,
812 actor: impl Into<String>,
813 ) -> Self {
814 Self {
815 verb: verb.into(),
816 substrate,
817 actor: Some(actor.into()),
818 kind: EventKind::Audit,
819 payload: EventPayload::default(),
820 payload_schema_version: 1,
821 profile_state_version: None,
822 aggregate: None,
823 }
824 }
825
826 pub fn kind(mut self, kind: EventKind) -> Self {
828 self.kind = kind;
829 self
830 }
831
832 pub fn payload(mut self, payload: EventPayload) -> Self {
834 self.payload = payload;
835 self
836 }
837
838 pub fn payload_schema_version(mut self, version: u32) -> Self {
840 self.payload_schema_version = version;
841 self
842 }
843
844 pub fn profile_state_version(mut self, version: u64) -> Self {
846 self.profile_state_version = Some(version);
847 self
848 }
849
850 pub fn aggregate(mut self, aggregate: AggregateRef) -> Self {
852 self.aggregate = Some(aggregate);
853 self
854 }
855
856 pub fn build(self, header: Header) -> Event {
858 Event {
859 header,
860 verb: self.verb,
861 substrate: self.substrate,
862 actor: self.actor,
863 kind: self.kind,
864 payload: self.payload,
865 payload_schema_version: self.payload_schema_version,
866 profile_state_version: self.profile_state_version,
867 aggregate: self.aggregate,
868 }
869 }
870}
871
872#[cfg(test)]
873mod tests {
874 extern crate alloc;
875
876 use super::*;
877 use crate::{Namespace, Timestamp};
878 #[cfg(feature = "serde")]
879 use alloc::string::ToString;
880
881 fn header() -> Header {
882 Header::new(
883 Id128::from_u128(1),
884 Namespace::local(),
885 Timestamp::from_secs(1700000000),
886 )
887 }
888
889 #[test]
890 fn event_kind_parse_roundtrip() {
891 for kind in EventKind::ALL {
892 let parsed: EventKind = kind
893 .name()
894 .parse()
895 .expect("EventKind::name must parse back");
896 assert_eq!(parsed, kind);
897 }
898 }
899
900 #[test]
901 fn rerank_payload_records_served_profile() {
902 let payload = EventPayload::RerankExecuted(RerankExecutedPayload {
903 served_by_profile_id: Some("profile-a".into()),
904 model_id: Id128::from_u128(1),
905 candidates: Vec::new(),
906 reranked: Vec::new(),
907 final_scores: Vec::new(),
908 latency_us: 100,
909 hook_applied: false,
910 hook_target_match: false,
911 });
912 let event = EventBuilder::new("rerank", SubstrateKind::Note, "agent:test")
913 .kind(EventKind::RerankExecuted)
914 .payload(payload)
915 .build(header());
916
917 if let EventPayload::RerankExecuted(ref p) = event.payload {
918 assert_eq!(p.served_by_profile_id.as_deref(), Some("profile-a"));
919 } else {
920 panic!("unexpected payload variant");
921 }
922 }
923
924 #[test]
925 fn proposal_payloads_are_typed() {
926 let payload = EventPayload::ProposalReviewed(ProposalReviewedPayload {
927 proposal_id: Id128::from_u128(42),
928 reviewer: "operator".into(),
929 decision: ProposalDecision::Approve,
930 comment: None,
931 });
932 let event = EventBuilder::new("review", SubstrateKind::Entity, "operator")
933 .kind(EventKind::ProposalReviewed)
934 .payload(payload)
935 .build(header());
936 assert_eq!(event.kind.name(), "proposal_reviewed");
937 }
938
939 #[cfg(feature = "serde")]
944 #[test]
945 fn proposal_changeset_id_variants_deserialize_from_value() {
946 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
947 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
948
949 let v =
951 serde_json::json!({"kind": "update_entity", "id": uuid, "patch": {"name": "NewName"}});
952 let cs: ProposalChangeset =
953 serde_json::from_value(v).expect("UpdateEntity must deserialize from Value");
954 assert!(
955 matches!(cs, ProposalChangeset::UpdateEntity { .. }),
956 "expected UpdateEntity"
957 );
958
959 let v = serde_json::json!({
961 "kind": "add_edge",
962 "source": uuid, "target": uuid2,
963 "relation": "extends", "weight": 1.0
964 });
965 let cs: ProposalChangeset =
966 serde_json::from_value(v).expect("AddEdge must deserialize from Value");
967 assert!(
968 matches!(cs, ProposalChangeset::AddEdge { .. }),
969 "expected AddEdge"
970 );
971
972 let v = serde_json::json!({"kind": "merge_entities", "into": uuid, "from": uuid2});
974 let cs: ProposalChangeset =
975 serde_json::from_value(v).expect("MergeEntities must deserialize from Value");
976 assert!(
977 matches!(cs, ProposalChangeset::MergeEntities { .. }),
978 "expected MergeEntities"
979 );
980
981 let v = serde_json::json!({"kind": "supersede_entity", "old": uuid, "new": uuid2});
983 let cs: ProposalChangeset =
984 serde_json::from_value(v).expect("SupersedeEntity must deserialize from Value");
985 assert!(
986 matches!(cs, ProposalChangeset::SupersedeEntity { .. }),
987 "expected SupersedeEntity"
988 );
989 }
990
991 #[cfg(feature = "serde")]
992 #[test]
993 fn proposal_changeset_rejects_invalid_edge_weight() {
994 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
995 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
996
997 let v = serde_json::json!({
998 "kind": "add_edge",
999 "source": uuid, "target": uuid2,
1000 "relation": "extends", "weight": 2.0
1001 });
1002 let result: Result<ProposalChangeset, _> = serde_json::from_value(v);
1003 assert!(result.is_err());
1004 let err = result.unwrap_err().to_string();
1005 assert!(
1006 err.contains("[0.0, 1.0]"),
1007 "error should mention range: {err}"
1008 );
1009 }
1010
1011 #[cfg(feature = "serde")]
1012 #[test]
1013 fn proposal_changeset_accepts_null_edge_weight() {
1014 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
1015 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
1016
1017 let v = serde_json::json!({
1018 "kind": "add_edge",
1019 "source": uuid, "target": uuid2,
1020 "relation": "extends", "weight": null
1021 });
1022 let cs: ProposalChangeset =
1023 serde_json::from_value(v).expect("null weight should be accepted");
1024 assert!(matches!(
1025 cs,
1026 ProposalChangeset::AddEdge { weight: None, .. }
1027 ));
1028 }
1029
1030 #[cfg(feature = "serde")]
1031 #[test]
1032 fn rerank_payload_serde_rejects_non_finite_score() {
1033 let json = serde_json::json!({
1034 "served_by_profile_id": null,
1035 "model_id": "00000000-0000-0000-0000-000000000001",
1036 "candidates": [],
1037 "reranked": [],
1038 "final_scores": [["00000000-0000-0000-0000-000000000001", "Infinity"]],
1039 "latency_us": 100,
1040 "hook_applied": false,
1041 "hook_target_match": false
1042 });
1043 let result: Result<RerankExecutedPayload, _> = serde_json::from_value(json);
1044 assert!(result.is_err());
1045 }
1046
1047 #[test]
1048 fn rerank_payload_is_valid_checks_finite() {
1049 let p = RerankExecutedPayload {
1050 served_by_profile_id: None,
1051 model_id: Id128::from_u128(1),
1052 candidates: Vec::new(),
1053 reranked: Vec::new(),
1054 final_scores: alloc::vec![(Id128::from_u128(1), 0.5)],
1055 latency_us: 100,
1056 hook_applied: false,
1057 hook_target_match: false,
1058 };
1059 assert!(p.is_valid());
1060
1061 let p_inf = RerankExecutedPayload {
1062 served_by_profile_id: None,
1063 model_id: Id128::from_u128(1),
1064 candidates: Vec::new(),
1065 reranked: Vec::new(),
1066 final_scores: alloc::vec![(Id128::from_u128(1), f32::INFINITY)],
1067 latency_us: 100,
1068 hook_applied: false,
1069 hook_target_match: false,
1070 };
1071 assert!(!p_inf.is_valid());
1072 }
1073}