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