Skip to main content

khive_types/
event.rs

1//! Event substrate — universal system log.
2//!
3//! Every verb execution produces an Event. Audit, usage metering, derived
4//! state, and evolutionary learning (edge reinforcement, traversal history)
5//! are all computed via Fold over the Event stream.
6
7extern crate alloc;
8use alloc::string::String;
9use alloc::vec::Vec;
10use core::fmt;
11
12use crate::{Header, Id128, SubstrateKind};
13
14/// A system event. Append-only, never mutated or deleted.
15#[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    /// The verb that produced the event.
21    pub verb: String,
22    /// Which substrate type was acted upon.
23    pub substrate: SubstrateKind,
24    /// Who performed the action. Profile- or system-produced events may omit it.
25    pub actor: Option<String>,
26    /// Typed event discriminant used by replay, projections, and workers.
27    pub kind: EventKind,
28    /// Typed payload surface for known event families; raw JSON is still allowed.
29    pub payload: EventPayload,
30    /// Payload schema version interpreted per `kind`.
31    pub payload_schema_version: u32,
32    /// Brain profile state version observed when the event was emitted.
33    pub profile_state_version: Option<u64>,
34    /// Logical aggregate threaded across related event ids.
35    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
369/// Builder for events. Used by the verb dispatch path.
370pub 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}