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    #[cfg(feature = "serde")]
250    ProposalCreated(ProposalCreatedPayload),
251    ProposalReviewed(ProposalReviewedPayload),
252    ProposalApplied(ProposalAppliedPayload),
253    ProposalWithdrawn(ProposalWithdrawnPayload),
254}
255
256impl Default for EventPayload {
257    fn default() -> Self {
258        Self::Json("{}".into())
259    }
260}
261
262#[derive(Clone, Debug, PartialEq)]
263#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
264pub struct RerankExecutedPayload {
265    pub served_by_profile_id: Option<String>,
266    pub model_id: Id128,
267    pub candidates: Vec<Id128>,
268    pub reranked: Vec<(Id128, Vec<(String, f32)>)>,
269    pub final_scores: Vec<(Id128, f32)>,
270    pub latency_us: u64,
271    pub hook_applied: bool,
272    pub hook_target_match: bool,
273}
274
275#[cfg(feature = "serde")]
276#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
277pub struct ProposalCreatedPayload {
278    pub proposal_id: Id128,
279    pub proposer: String,
280    pub title: String,
281    pub description: String,
282    pub changeset: ProposalChangeset,
283    pub reviewers: Vec<String>,
284    pub expiry: Option<crate::Timestamp>,
285    pub parent_id: Option<Id128>,
286}
287
288/// Structured draft for adding a new entity via a proposal (ADR-046:100).
289///
290/// Fields mirror the `create(kind=<entity kind>)` verb surface; `kind` is
291/// validated against the closed 8-kind taxonomy (ADR-001) at apply time.
292#[cfg(feature = "serde")]
293#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
294pub struct EntityDraft {
295    /// Entity kind — must be one of the 8 closed ADR-001 values.
296    pub kind: String,
297    /// Human-readable name (required).
298    pub name: String,
299    /// Optional long-form description.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub description: Option<String>,
302    /// Arbitrary structured metadata.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub properties: Option<serde_json::Value>,
305    /// Classification tags.
306    #[serde(default, skip_serializing_if = "Vec::is_empty")]
307    pub tags: Vec<String>,
308}
309
310/// Structured patch for modifying an existing entity via a proposal (ADR-046:101).
311///
312/// Absent fields mean "leave unchanged". Setting `description` to `null` clears it.
313#[cfg(feature = "serde")]
314#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
315pub struct ProposalEntityPatch {
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub name: Option<String>,
318    /// `null` clears the description; absent leaves it unchanged.
319    #[serde(
320        default,
321        skip_serializing_if = "Option::is_none",
322        with = "serde_opt_opt"
323    )]
324    pub description: Option<Option<String>>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub properties: Option<serde_json::Value>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub tags: Option<Vec<String>>,
329}
330
331/// Structured draft for adding a new note via a proposal (ADR-046:106).
332///
333/// Fields mirror the `create(kind=<note kind>)` verb surface.
334#[cfg(feature = "serde")]
335#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
336pub struct NoteDraft {
337    /// Note kind string (validated by the loaded pack at apply time).
338    pub kind: String,
339    /// Note body / content (required).
340    pub content: String,
341    /// Optional short name.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub name: Option<String>,
344    /// Arbitrary structured metadata.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub properties: Option<serde_json::Value>,
347}
348
349/// Serde helper for `Option<Option<T>>` — distinguishes absent vs. explicit null.
350#[cfg(feature = "serde")]
351mod serde_opt_opt {
352    use serde::{Deserialize, Deserializer, Serialize, Serializer};
353
354    pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
355    where
356        T: Serialize,
357        S: Serializer,
358    {
359        match val {
360            None => unreachable!("skip_serializing_if guards the None case"),
361            Some(inner) => inner.serialize(s),
362        }
363    }
364
365    pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
366    where
367        T: Deserialize<'de>,
368        D: Deserializer<'de>,
369    {
370        let opt: Option<T> = Option::deserialize(d)?;
371        Ok(Some(opt))
372    }
373}
374
375#[cfg(feature = "serde")]
376#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
377#[serde(tag = "kind", rename_all = "snake_case")]
378pub enum ProposalChangeset {
379    /// Add a new entity. `entity.kind` validated against ADR-001 at apply time.
380    AddEntity {
381        entity: EntityDraft,
382    },
383    /// Modify an existing entity's properties / tags / description.
384    UpdateEntity {
385        id: Id128,
386        patch: ProposalEntityPatch,
387    },
388    AddEdge {
389        source: Id128,
390        target: Id128,
391        relation: crate::EdgeRelation,
392        weight: Option<f32>,
393    },
394    /// Add a note (entity-annotating or stand-alone).
395    AddNote {
396        note: NoteDraft,
397    },
398    MergeEntities {
399        into: Id128,
400        from: Id128,
401    },
402    SupersedeEntity {
403        old: Id128,
404        new: Id128,
405    },
406    Compound {
407        steps: Vec<ProposalChangeset>,
408    },
409}
410
411#[cfg(not(feature = "serde"))]
412#[derive(Clone, Debug, PartialEq)]
413pub enum ProposalChangeset {
414    AddEdge {
415        source: Id128,
416        target: Id128,
417        relation: crate::EdgeRelation,
418        weight: Option<f32>,
419    },
420    MergeEntities {
421        into: Id128,
422        from: Id128,
423    },
424    SupersedeEntity {
425        old: Id128,
426        new: Id128,
427    },
428    Compound {
429        steps: Vec<ProposalChangeset>,
430    },
431}
432
433#[derive(Clone, Debug, PartialEq)]
434#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
435pub struct ProposalReviewedPayload {
436    pub proposal_id: Id128,
437    pub reviewer: String,
438    pub decision: ProposalDecision,
439    pub comment: Option<String>,
440}
441
442#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
443#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
444#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
445pub enum ProposalDecision {
446    Approve,
447    Reject,
448    Comment,
449    RequestChanges,
450}
451
452impl ProposalDecision {
453    /// Returns the bare variant name as a lowercase string, matching the serde
454    /// `rename_all = "snake_case"` representation.  Use this when storing the
455    /// decision as a plain TEXT column — **not** `serde_json::to_string`, which
456    /// would produce a JSON-quoted string (`"\"approve\""` instead of `"approve"`).
457    pub fn as_str(self) -> &'static str {
458        match self {
459            Self::Approve => "approve",
460            Self::Reject => "reject",
461            Self::Comment => "comment",
462            Self::RequestChanges => "request_changes",
463        }
464    }
465}
466
467#[derive(Clone, Debug, PartialEq)]
468#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
469pub struct ProposalAppliedPayload {
470    pub proposal_id: Id128,
471    pub applied_at: crate::Timestamp,
472    pub applied_by: String,
473    pub result: ApplyResult,
474}
475
476#[derive(Clone, Debug, PartialEq)]
477#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
478#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
479pub enum ApplyResult {
480    Success {
481        created_records: Vec<Id128>,
482    },
483    Failed {
484        error: String,
485        applied_step_count: u32,
486    },
487}
488
489#[derive(Clone, Debug, PartialEq)]
490#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
491pub struct ProposalWithdrawnPayload {
492    pub proposal_id: Id128,
493    pub by: String,
494    pub reason: Option<String>,
495}
496
497/// Builder for events. Used by the verb dispatch path.
498pub struct EventBuilder {
499    verb: String,
500    substrate: SubstrateKind,
501    actor: Option<String>,
502    kind: EventKind,
503    payload: EventPayload,
504    payload_schema_version: u32,
505    profile_state_version: Option<u64>,
506    aggregate: Option<AggregateRef>,
507}
508
509impl EventBuilder {
510    pub fn new(
511        verb: impl Into<String>,
512        substrate: SubstrateKind,
513        actor: impl Into<String>,
514    ) -> Self {
515        Self {
516            verb: verb.into(),
517            substrate,
518            actor: Some(actor.into()),
519            kind: EventKind::Audit,
520            payload: EventPayload::default(),
521            payload_schema_version: 1,
522            profile_state_version: None,
523            aggregate: None,
524        }
525    }
526
527    pub fn kind(mut self, kind: EventKind) -> Self {
528        self.kind = kind;
529        self
530    }
531
532    pub fn payload(mut self, payload: EventPayload) -> Self {
533        self.payload = payload;
534        self
535    }
536
537    pub fn payload_schema_version(mut self, version: u32) -> Self {
538        self.payload_schema_version = version;
539        self
540    }
541
542    pub fn profile_state_version(mut self, version: u64) -> Self {
543        self.profile_state_version = Some(version);
544        self
545    }
546
547    pub fn aggregate(mut self, aggregate: AggregateRef) -> Self {
548        self.aggregate = Some(aggregate);
549        self
550    }
551
552    pub fn build(self, header: Header) -> Event {
553        Event {
554            header,
555            verb: self.verb,
556            substrate: self.substrate,
557            actor: self.actor,
558            kind: self.kind,
559            payload: self.payload,
560            payload_schema_version: self.payload_schema_version,
561            profile_state_version: self.profile_state_version,
562            aggregate: self.aggregate,
563        }
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    extern crate alloc;
570
571    use super::*;
572    use crate::{Namespace, Timestamp};
573
574    fn header() -> Header {
575        Header::new(
576            Id128::from_u128(1),
577            Namespace::local(),
578            Timestamp::from_secs(1700000000),
579        )
580    }
581
582    #[test]
583    fn event_kind_parse_roundtrip() {
584        for kind in EventKind::ALL {
585            let parsed: EventKind = kind
586                .name()
587                .parse()
588                .expect("EventKind::name must parse back");
589            assert_eq!(parsed, kind);
590        }
591    }
592
593    #[test]
594    fn rerank_payload_records_served_profile() {
595        let payload = EventPayload::RerankExecuted(RerankExecutedPayload {
596            served_by_profile_id: Some("profile-a".into()),
597            model_id: Id128::from_u128(1),
598            candidates: Vec::new(),
599            reranked: Vec::new(),
600            final_scores: Vec::new(),
601            latency_us: 100,
602            hook_applied: false,
603            hook_target_match: false,
604        });
605        let event = EventBuilder::new("rerank", SubstrateKind::Note, "agent:test")
606            .kind(EventKind::RerankExecuted)
607            .payload(payload)
608            .build(header());
609
610        if let EventPayload::RerankExecuted(ref p) = event.payload {
611            assert_eq!(p.served_by_profile_id.as_deref(), Some("profile-a"));
612        } else {
613            panic!("unexpected payload variant");
614        }
615    }
616
617    #[test]
618    fn proposal_payloads_are_typed() {
619        let payload = EventPayload::ProposalReviewed(ProposalReviewedPayload {
620            proposal_id: Id128::from_u128(42),
621            reviewer: "ocean".into(),
622            decision: ProposalDecision::Approve,
623            comment: None,
624        });
625        let event = EventBuilder::new("review", SubstrateKind::Entity, "ocean")
626            .kind(EventKind::ProposalReviewed)
627            .payload(payload)
628            .build(header());
629        assert_eq!(event.kind.name(), "proposal_reviewed");
630    }
631
632    /// C1 regression: all ProposalChangeset variants that carry Id128 fields must
633    /// round-trip through serde_json::Value.  Previously `Id128::deserialize` used
634    /// `<&str>::deserialize` which fails when the deserializer holds owned data
635    /// (the Value-backed path used by the MCP DSL parser).
636    #[cfg(feature = "serde")]
637    #[test]
638    fn proposal_changeset_id_variants_deserialize_from_value() {
639        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
640        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
641
642        // UpdateEntity — patch is now a structured ProposalEntityPatch object
643        let v =
644            serde_json::json!({"kind": "update_entity", "id": uuid, "patch": {"name": "NewName"}});
645        let cs: ProposalChangeset =
646            serde_json::from_value(v).expect("UpdateEntity must deserialize from Value");
647        assert!(
648            matches!(cs, ProposalChangeset::UpdateEntity { .. }),
649            "expected UpdateEntity"
650        );
651
652        // AddEdge
653        let v = serde_json::json!({
654            "kind": "add_edge",
655            "source": uuid, "target": uuid2,
656            "relation": "extends", "weight": 1.0
657        });
658        let cs: ProposalChangeset =
659            serde_json::from_value(v).expect("AddEdge must deserialize from Value");
660        assert!(
661            matches!(cs, ProposalChangeset::AddEdge { .. }),
662            "expected AddEdge"
663        );
664
665        // MergeEntities
666        let v = serde_json::json!({"kind": "merge_entities", "into": uuid, "from": uuid2});
667        let cs: ProposalChangeset =
668            serde_json::from_value(v).expect("MergeEntities must deserialize from Value");
669        assert!(
670            matches!(cs, ProposalChangeset::MergeEntities { .. }),
671            "expected MergeEntities"
672        );
673
674        // SupersedeEntity
675        let v = serde_json::json!({"kind": "supersede_entity", "old": uuid, "new": uuid2});
676        let cs: ProposalChangeset =
677            serde_json::from_value(v).expect("SupersedeEntity must deserialize from Value");
678        assert!(
679            matches!(cs, ProposalChangeset::SupersedeEntity { .. }),
680            "expected SupersedeEntity"
681        );
682    }
683}