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