Skip to main content

khive_types/
event.rs

1//! Event substrate — append-only log produced by every verb execution.
2
3extern crate alloc;
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7
8use crate::{Header, Id128, SubstrateKind};
9
10/// A system event. Append-only, never mutated or deleted.
11#[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    /// The verb that produced the event.
17    pub verb: String,
18    /// Which substrate type was acted upon.
19    pub substrate: SubstrateKind,
20    /// Who performed the action. Profile- or system-produced events may omit it.
21    pub actor: Option<String>,
22    /// Typed event discriminant used by replay, projections, and workers.
23    pub kind: EventKind,
24    /// Typed payload surface for known event families; raw JSON is still allowed.
25    pub payload: EventPayload,
26    /// Payload schema version interpreted per `kind`.
27    pub payload_schema_version: u32,
28    /// Brain profile state version observed when the event was emitted.
29    pub profile_state_version: Option<u64>,
30    /// Logical aggregate threaded across related event ids.
31    pub aggregate: Option<AggregateRef>,
32}
33
34/// Outcome of a verb execution recorded in an event log entry.
35#[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    /// The verb executed successfully.
40    #[default]
41    Success,
42    /// The verb was denied by a policy check.
43    Denied,
44    /// The verb encountered a runtime error.
45    Error,
46}
47
48impl EventOutcome {
49    /// Return the canonical lowercase string for this outcome.
50    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/// Discriminant for the 26 typed event variants produced by the verb dispatch path.
66#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
69pub enum EventKind {
70    /// Generic audit event with no structured payload.
71    Audit,
72    /// A `recall` verb was executed and results were returned.
73    RecallExecuted,
74    /// A rerank pass was applied to search candidates.
75    RerankExecuted,
76    /// A `search` verb was executed.
77    SearchExecuted,
78    /// A new directed edge was created between two nodes.
79    LinkCreated,
80    /// A new entity was created.
81    EntityCreated,
82    /// An existing entity was patched.
83    EntityUpdated,
84    /// An entity was soft- or hard-deleted.
85    EntityDeleted,
86    /// Two entities were merged (deduplication).
87    EntityMerged,
88    /// A new note was created.
89    NoteCreated,
90    /// An existing note was patched.
91    NoteUpdated,
92    /// A note was soft- or hard-deleted.
93    NoteDeleted,
94    /// An edge's relation or weight was updated.
95    EdgeUpdated,
96    /// An edge was removed.
97    EdgeDeleted,
98    /// A GTD task moved between lifecycle states.
99    TaskTransitioned,
100    /// An explicit user feedback signal was recorded.
101    FeedbackExplicit,
102    /// The brain recommended a profile resolution update.
103    ProfileResolutionRecommended,
104    /// Two brain profiles were merged.
105    ProfileMerged,
106    /// The active embedding model was changed.
107    EmbeddingModelChanged,
108    /// An embedding migration batch completed successfully.
109    EmbeddingMigrationCompleted,
110    /// An embedding migration batch failed.
111    EmbeddingMigrationFailed,
112    /// Drift was detected between stored and live embeddings.
113    EmbeddingDriftDetected,
114    /// A proposal was submitted for review.
115    ProposalCreated,
116    /// A reviewer accepted, rejected, or commented on a proposal.
117    ProposalReviewed,
118    /// A proposal was applied to the graph.
119    ProposalApplied,
120    /// A proposal was withdrawn before it was applied.
121    ProposalWithdrawn,
122}
123
124impl EventKind {
125    /// All 26 event kind variants in declaration order.
126    pub const ALL: [Self; 26] = [
127        Self::Audit,
128        Self::RecallExecuted,
129        Self::RerankExecuted,
130        Self::SearchExecuted,
131        Self::LinkCreated,
132        Self::EntityCreated,
133        Self::EntityUpdated,
134        Self::EntityDeleted,
135        Self::EntityMerged,
136        Self::NoteCreated,
137        Self::NoteUpdated,
138        Self::NoteDeleted,
139        Self::EdgeUpdated,
140        Self::EdgeDeleted,
141        Self::TaskTransitioned,
142        Self::FeedbackExplicit,
143        Self::ProfileResolutionRecommended,
144        Self::ProfileMerged,
145        Self::EmbeddingModelChanged,
146        Self::EmbeddingMigrationCompleted,
147        Self::EmbeddingMigrationFailed,
148        Self::EmbeddingDriftDetected,
149        Self::ProposalCreated,
150        Self::ProposalReviewed,
151        Self::ProposalApplied,
152        Self::ProposalWithdrawn,
153    ];
154
155    /// Return the canonical snake_case string for this event kind.
156    pub const fn name(self) -> &'static str {
157        match self {
158            Self::Audit => "audit",
159            Self::RecallExecuted => "recall_executed",
160            Self::RerankExecuted => "rerank_executed",
161            Self::SearchExecuted => "search_executed",
162            Self::LinkCreated => "link_created",
163            Self::EntityCreated => "entity_created",
164            Self::EntityUpdated => "entity_updated",
165            Self::EntityDeleted => "entity_deleted",
166            Self::EntityMerged => "entity_merged",
167            Self::NoteCreated => "note_created",
168            Self::NoteUpdated => "note_updated",
169            Self::NoteDeleted => "note_deleted",
170            Self::EdgeUpdated => "edge_updated",
171            Self::EdgeDeleted => "edge_deleted",
172            Self::TaskTransitioned => "task_transitioned",
173            Self::FeedbackExplicit => "feedback_explicit",
174            Self::ProfileResolutionRecommended => "profile_resolution_recommended",
175            Self::ProfileMerged => "profile_merged",
176            Self::EmbeddingModelChanged => "embedding_model_changed",
177            Self::EmbeddingMigrationCompleted => "embedding_migration_completed",
178            Self::EmbeddingMigrationFailed => "embedding_migration_failed",
179            Self::EmbeddingDriftDetected => "embedding_drift_detected",
180            Self::ProposalCreated => "proposal_created",
181            Self::ProposalReviewed => "proposal_reviewed",
182            Self::ProposalApplied => "proposal_applied",
183            Self::ProposalWithdrawn => "proposal_withdrawn",
184        }
185    }
186}
187
188impl fmt::Display for EventKind {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        f.write_str(self.name())
191    }
192}
193
194const EVENT_KIND_VALID: &[&str] = &[
195    "audit",
196    "recall_executed",
197    "rerank_executed",
198    "search_executed",
199    "link_created",
200    "entity_created",
201    "entity_updated",
202    "entity_deleted",
203    "entity_merged",
204    "note_created",
205    "note_updated",
206    "note_deleted",
207    "edge_updated",
208    "edge_deleted",
209    "task_transitioned",
210    "feedback_explicit",
211    "profile_resolution_recommended",
212    "profile_merged",
213    "embedding_model_changed",
214    "embedding_migration_completed",
215    "embedding_migration_failed",
216    "embedding_drift_detected",
217    "proposal_created",
218    "proposal_reviewed",
219    "proposal_applied",
220    "proposal_withdrawn",
221];
222
223impl core::str::FromStr for EventKind {
224    type Err = crate::error::UnknownVariant;
225
226    fn from_str(s: &str) -> Result<Self, Self::Err> {
227        match s.trim().to_ascii_lowercase().as_str() {
228            "audit" => Ok(Self::Audit),
229            "recall_executed" => Ok(Self::RecallExecuted),
230            "rerank_executed" => Ok(Self::RerankExecuted),
231            "search_executed" => Ok(Self::SearchExecuted),
232            "link_created" => Ok(Self::LinkCreated),
233            "entity_created" => Ok(Self::EntityCreated),
234            "entity_updated" => Ok(Self::EntityUpdated),
235            "entity_deleted" => Ok(Self::EntityDeleted),
236            "entity_merged" => Ok(Self::EntityMerged),
237            "note_created" => Ok(Self::NoteCreated),
238            "note_updated" => Ok(Self::NoteUpdated),
239            "note_deleted" => Ok(Self::NoteDeleted),
240            "edge_updated" => Ok(Self::EdgeUpdated),
241            "edge_deleted" => Ok(Self::EdgeDeleted),
242            "task_transitioned" => Ok(Self::TaskTransitioned),
243            "feedback_explicit" => Ok(Self::FeedbackExplicit),
244            "profile_resolution_recommended" => Ok(Self::ProfileResolutionRecommended),
245            "profile_merged" => Ok(Self::ProfileMerged),
246            "embedding_model_changed" => Ok(Self::EmbeddingModelChanged),
247            "embedding_migration_completed" => Ok(Self::EmbeddingMigrationCompleted),
248            "embedding_migration_failed" => Ok(Self::EmbeddingMigrationFailed),
249            "embedding_drift_detected" => Ok(Self::EmbeddingDriftDetected),
250            "proposal_created" => Ok(Self::ProposalCreated),
251            "proposal_reviewed" => Ok(Self::ProposalReviewed),
252            "proposal_applied" => Ok(Self::ProposalApplied),
253            "proposal_withdrawn" => Ok(Self::ProposalWithdrawn),
254            other => Err(crate::error::UnknownVariant::new(
255                "event_kind",
256                other,
257                EVENT_KIND_VALID,
258            )),
259        }
260    }
261}
262
263/// A reference to the logical aggregate that an event belongs to.
264///
265/// Used to thread related events (e.g. proposal lifecycle events) into a
266/// single auditable chain identified by `kind` and `id`.
267#[derive(Clone, Debug, PartialEq, Eq)]
268#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
269pub struct AggregateRef {
270    /// The aggregate type string (e.g. `"proposal"`).
271    pub kind: String,
272    /// The aggregate instance identifier.
273    pub id: Id128,
274}
275
276/// Typed payload for an [`Event`], dispatched by [`EventKind`].
277///
278/// The `Json` variant is a catch-all for events whose payload has not yet
279/// been promoted to a structured type. All other variants carry a concrete
280/// typed struct that can be pattern-matched without round-tripping through JSON.
281#[derive(Clone, Debug, PartialEq)]
282#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
283#[cfg_attr(
284    feature = "serde",
285    serde(tag = "kind", content = "payload", rename_all = "snake_case")
286)]
287pub enum EventPayload {
288    /// Raw JSON payload for untyped events.
289    Json(String),
290    /// Structured payload for a rerank pass event.
291    RerankExecuted(RerankExecutedPayload),
292    /// Structured payload for a proposal-created event (requires `serde` feature).
293    #[cfg(feature = "serde")]
294    ProposalCreated(ProposalCreatedPayload),
295    /// Structured payload for a proposal-reviewed event.
296    ProposalReviewed(ProposalReviewedPayload),
297    /// Structured payload for a proposal-applied event.
298    ProposalApplied(ProposalAppliedPayload),
299    /// Structured payload for a proposal-withdrawn event.
300    ProposalWithdrawn(ProposalWithdrawnPayload),
301}
302
303impl Default for EventPayload {
304    fn default() -> Self {
305        Self::Json("{}".into())
306    }
307}
308
309/// Payload for a rerank pass event, recording per-candidate scores.
310///
311/// All score values (`reranked` section scores, `final_scores`) must be finite.
312/// When the `serde` feature is enabled, deserialization rejects non-finite scores.
313#[derive(Clone, Debug, PartialEq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize))]
315pub struct RerankExecutedPayload {
316    /// Brain profile that served this rerank, if any.
317    pub served_by_profile_id: Option<String>,
318    /// Model used for reranking.
319    pub model_id: Id128,
320    /// Candidate IDs in input order.
321    pub candidates: Vec<Id128>,
322    /// Per-candidate named sub-scores from the reranker.
323    pub reranked: Vec<(Id128, Vec<(String, f32)>)>,
324    /// Final aggregated score per candidate.
325    pub final_scores: Vec<(Id128, f32)>,
326    /// Wall-clock latency of the rerank operation in microseconds.
327    pub latency_us: u64,
328    /// Whether a brain hook was applied during this rerank.
329    pub hook_applied: bool,
330    /// Whether the hook matched the intended target.
331    pub hook_target_match: bool,
332}
333
334impl RerankExecutedPayload {
335    /// Return `true` if all score values are finite.
336    pub fn is_valid(&self) -> bool {
337        let reranked_ok = self
338            .reranked
339            .iter()
340            .all(|(_, scores)| scores.iter().all(|(_, s)| s.is_finite()));
341        let final_ok = self.final_scores.iter().all(|(_, s)| s.is_finite());
342        reranked_ok && final_ok
343    }
344}
345
346#[cfg(feature = "serde")]
347impl<'de> serde::Deserialize<'de> for RerankExecutedPayload {
348    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
349    where
350        D: serde::Deserializer<'de>,
351    {
352        #[derive(serde::Deserialize)]
353        struct Raw {
354            served_by_profile_id: Option<String>,
355            model_id: Id128,
356            candidates: Vec<Id128>,
357            reranked: Vec<(Id128, Vec<(String, f32)>)>,
358            final_scores: Vec<(Id128, f32)>,
359            latency_us: u64,
360            hook_applied: bool,
361            hook_target_match: bool,
362        }
363
364        let raw = Raw::deserialize(deserializer)?;
365
366        for (_, score) in &raw.final_scores {
367            if !score.is_finite() {
368                return Err(serde::de::Error::custom(alloc::format!(
369                    "RerankExecutedPayload final_scores must be finite, got {score}"
370                )));
371            }
372        }
373        for (_, sections) in &raw.reranked {
374            for (section_name, score) in sections {
375                if !score.is_finite() {
376                    return Err(serde::de::Error::custom(alloc::format!(
377                        "RerankExecutedPayload reranked section '{section_name}' score must be finite, got {score}"
378                    )));
379                }
380            }
381        }
382
383        Ok(RerankExecutedPayload {
384            served_by_profile_id: raw.served_by_profile_id,
385            model_id: raw.model_id,
386            candidates: raw.candidates,
387            reranked: raw.reranked,
388            final_scores: raw.final_scores,
389            latency_us: raw.latency_us,
390            hook_applied: raw.hook_applied,
391            hook_target_match: raw.hook_target_match,
392        })
393    }
394}
395
396/// Payload for the `ProposalCreated` event — captures the full initial proposal state.
397#[cfg(feature = "serde")]
398#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
399pub struct ProposalCreatedPayload {
400    pub proposal_id: Id128,
401    pub proposer: String,
402    pub title: String,
403    pub description: String,
404    pub changeset: ProposalChangeset,
405    pub reviewers: Vec<String>,
406    pub expiry: Option<crate::Timestamp>,
407    pub parent_id: Option<Id128>,
408}
409
410/// Structured draft for adding a new entity via a proposal.
411///
412/// Fields mirror the `create(kind=<entity kind>)` verb surface; `kind` is
413/// validated against the closed 8-kind entity taxonomy at apply time.
414#[cfg(feature = "serde")]
415#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
416pub struct EntityDraft {
417    /// Entity kind — must be one of the 8 closed entity kind values.
418    pub kind: String,
419    /// Human-readable name (required).
420    pub name: String,
421    /// Optional long-form description.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub description: Option<String>,
424    /// Arbitrary structured metadata.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub properties: Option<serde_json::Value>,
427    /// Classification tags.
428    #[serde(default, skip_serializing_if = "Vec::is_empty")]
429    pub tags: Vec<String>,
430}
431
432/// Structured patch for modifying an existing entity via a proposal.
433///
434/// Absent fields mean "leave unchanged". Setting `description` to `null` clears it.
435#[cfg(feature = "serde")]
436#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
437pub struct ProposalEntityPatch {
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub name: Option<String>,
440    /// `null` clears the description; absent leaves it unchanged.
441    #[serde(
442        default,
443        skip_serializing_if = "Option::is_none",
444        with = "serde_opt_opt"
445    )]
446    pub description: Option<Option<String>>,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub properties: Option<serde_json::Value>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub tags: Option<Vec<String>>,
451}
452
453/// Structured draft for adding a new note via a proposal.
454///
455/// Fields mirror the `create(kind=<note kind>)` verb surface.
456#[cfg(feature = "serde")]
457#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
458pub struct NoteDraft {
459    /// Note kind string (validated by the loaded pack at apply time).
460    pub kind: String,
461    /// Note body / content (required).
462    pub content: String,
463    /// Optional short name.
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub name: Option<String>,
466    /// Arbitrary structured metadata.
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub properties: Option<serde_json::Value>,
469}
470
471/// Serde helper for `Option<Option<T>>` — distinguishes absent vs. explicit null.
472#[cfg(feature = "serde")]
473mod serde_opt_opt {
474    use serde::{Deserialize, Deserializer, Serialize, Serializer};
475
476    pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
477    where
478        T: Serialize,
479        S: Serializer,
480    {
481        match val {
482            None => unreachable!("skip_serializing_if guards the None case"),
483            Some(inner) => inner.serialize(s),
484        }
485    }
486
487    pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
488    where
489        T: Deserialize<'de>,
490        D: Deserializer<'de>,
491    {
492        let opt: Option<T> = Option::deserialize(d)?;
493        Ok(Some(opt))
494    }
495}
496
497/// The set of KG mutations a proposal intends to apply as a proposal changeset.
498#[cfg(feature = "serde")]
499#[derive(Clone, Debug, PartialEq, serde::Serialize)]
500#[serde(tag = "kind", rename_all = "snake_case")]
501pub enum ProposalChangeset {
502    /// Add a new entity. `entity.kind` validated at apply time.
503    AddEntity {
504        entity: EntityDraft,
505    },
506    /// Modify an existing entity's properties / tags / description.
507    UpdateEntity {
508        id: Id128,
509        patch: ProposalEntityPatch,
510    },
511    /// Add a typed edge. `weight` must be finite and in `[0.0, 1.0]` if present.
512    AddEdge {
513        source: Id128,
514        target: Id128,
515        relation: crate::EdgeRelation,
516        weight: Option<f32>,
517    },
518    /// Add a note (entity-annotating or stand-alone).
519    AddNote {
520        note: NoteDraft,
521    },
522    MergeEntities {
523        into: Id128,
524        from: Id128,
525    },
526    SupersedeEntity {
527        old: Id128,
528        new: Id128,
529    },
530    Compound {
531        steps: Vec<ProposalChangeset>,
532    },
533}
534
535#[cfg(feature = "serde")]
536impl ProposalChangeset {
537    fn validate(&self) -> Result<(), alloc::string::String> {
538        match self {
539            Self::AddEdge { weight, .. } => {
540                if let Some(w) = weight {
541                    if !w.is_finite() {
542                        return Err(alloc::format!(
543                            "ProposalChangeset AddEdge weight must be finite, got {w}"
544                        ));
545                    }
546                    if !(*w >= 0.0 && *w <= 1.0) {
547                        return Err(alloc::format!(
548                            "ProposalChangeset AddEdge weight must be in [0.0, 1.0], got {w}"
549                        ));
550                    }
551                }
552                Ok(())
553            }
554            Self::Compound { steps } => {
555                for step in steps {
556                    step.validate()?;
557                }
558                Ok(())
559            }
560            _ => Ok(()),
561        }
562    }
563}
564
565#[cfg(feature = "serde")]
566impl<'de> serde::Deserialize<'de> for ProposalChangeset {
567    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
568    where
569        D: serde::Deserializer<'de>,
570    {
571        #[derive(serde::Deserialize)]
572        #[serde(tag = "kind", rename_all = "snake_case")]
573        enum ProposalChangesetRaw {
574            AddEntity {
575                entity: EntityDraft,
576            },
577            UpdateEntity {
578                id: Id128,
579                patch: ProposalEntityPatch,
580            },
581            AddEdge {
582                source: Id128,
583                target: Id128,
584                relation: crate::EdgeRelation,
585                weight: Option<f32>,
586            },
587            AddNote {
588                note: NoteDraft,
589            },
590            MergeEntities {
591                into: Id128,
592                from: Id128,
593            },
594            SupersedeEntity {
595                old: Id128,
596                new: Id128,
597            },
598            Compound {
599                steps: Vec<ProposalChangeset>,
600            },
601        }
602
603        let raw = ProposalChangesetRaw::deserialize(deserializer)?;
604        let cs = match raw {
605            ProposalChangesetRaw::AddEntity { entity } => Self::AddEntity { entity },
606            ProposalChangesetRaw::UpdateEntity { id, patch } => Self::UpdateEntity { id, patch },
607            ProposalChangesetRaw::AddEdge {
608                source,
609                target,
610                relation,
611                weight,
612            } => Self::AddEdge {
613                source,
614                target,
615                relation,
616                weight,
617            },
618            ProposalChangesetRaw::AddNote { note } => Self::AddNote { note },
619            ProposalChangesetRaw::MergeEntities { into, from } => {
620                Self::MergeEntities { into, from }
621            }
622            ProposalChangesetRaw::SupersedeEntity { old, new } => {
623                Self::SupersedeEntity { old, new }
624            }
625            ProposalChangesetRaw::Compound { steps } => Self::Compound { steps },
626        };
627        cs.validate().map_err(serde::de::Error::custom)?;
628        Ok(cs)
629    }
630}
631
632#[cfg(not(feature = "serde"))]
633#[derive(Clone, Debug, PartialEq)]
634pub enum ProposalChangeset {
635    AddEdge {
636        source: Id128,
637        target: Id128,
638        relation: crate::EdgeRelation,
639        weight: Option<f32>,
640    },
641    MergeEntities {
642        into: Id128,
643        from: Id128,
644    },
645    SupersedeEntity {
646        old: Id128,
647        new: Id128,
648    },
649    Compound {
650        steps: Vec<ProposalChangeset>,
651    },
652}
653
654/// Payload for the `ProposalReviewed` event — records a single reviewer's decision.
655#[derive(Clone, Debug, PartialEq)]
656#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
657pub struct ProposalReviewedPayload {
658    pub proposal_id: Id128,
659    pub reviewer: String,
660    pub decision: ProposalDecision,
661    pub comment: Option<String>,
662}
663
664/// A reviewer's decision on a proposal.
665#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
666#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
667#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
668pub enum ProposalDecision {
669    /// The reviewer approved the proposal for application.
670    Approve,
671    /// The reviewer rejected the proposal; it will not be applied.
672    Reject,
673    /// The reviewer left a comment without blocking the proposal.
674    Comment,
675    /// The reviewer requested changes before the proposal can proceed.
676    RequestChanges,
677}
678
679impl ProposalDecision {
680    /// Returns the bare variant name as a lowercase string, matching the serde
681    /// `rename_all = "snake_case"` representation.  Use this when storing the
682    /// decision as a plain TEXT column — **not** `serde_json::to_string`, which
683    /// would produce a JSON-quoted string (`"\"approve\""` instead of `"approve"`).
684    pub fn as_str(self) -> &'static str {
685        match self {
686            Self::Approve => "approve",
687            Self::Reject => "reject",
688            Self::Comment => "comment",
689            Self::RequestChanges => "request_changes",
690        }
691    }
692}
693
694/// Payload for the `ProposalApplied` event — records the outcome of the apply attempt.
695#[derive(Clone, Debug, PartialEq)]
696#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
697pub struct ProposalAppliedPayload {
698    pub proposal_id: Id128,
699    pub applied_at: crate::Timestamp,
700    pub applied_by: String,
701    pub result: ApplyResult,
702}
703
704/// Outcome of applying a proposal: either all steps succeeded or the apply failed with an error.
705#[derive(Clone, Debug, PartialEq)]
706#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
707#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
708pub enum ApplyResult {
709    Success {
710        created_records: Vec<Id128>,
711    },
712    Failed {
713        error: String,
714        applied_step_count: u32,
715    },
716}
717
718/// Payload for the `ProposalWithdrawn` event — records who withdrew and an optional reason.
719#[derive(Clone, Debug, PartialEq)]
720#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
721pub struct ProposalWithdrawnPayload {
722    pub proposal_id: Id128,
723    pub by: String,
724    pub reason: Option<String>,
725}
726
727/// Builder for events. Used by the verb dispatch path.
728pub struct EventBuilder {
729    verb: String,
730    substrate: SubstrateKind,
731    actor: Option<String>,
732    kind: EventKind,
733    payload: EventPayload,
734    payload_schema_version: u32,
735    profile_state_version: Option<u64>,
736    aggregate: Option<AggregateRef>,
737}
738
739impl EventBuilder {
740    /// Create a new builder for an event produced by `verb` acting on `substrate` as `actor`.
741    pub fn new(
742        verb: impl Into<String>,
743        substrate: SubstrateKind,
744        actor: impl Into<String>,
745    ) -> Self {
746        Self {
747            verb: verb.into(),
748            substrate,
749            actor: Some(actor.into()),
750            kind: EventKind::Audit,
751            payload: EventPayload::default(),
752            payload_schema_version: 1,
753            profile_state_version: None,
754            aggregate: None,
755        }
756    }
757
758    /// Override the event kind discriminant.
759    pub fn kind(mut self, kind: EventKind) -> Self {
760        self.kind = kind;
761        self
762    }
763
764    /// Set the typed payload for this event.
765    pub fn payload(mut self, payload: EventPayload) -> Self {
766        self.payload = payload;
767        self
768    }
769
770    /// Set the payload schema version (defaults to 1).
771    pub fn payload_schema_version(mut self, version: u32) -> Self {
772        self.payload_schema_version = version;
773        self
774    }
775
776    /// Record the brain profile state version observed at emit time.
777    pub fn profile_state_version(mut self, version: u64) -> Self {
778        self.profile_state_version = Some(version);
779        self
780    }
781
782    /// Thread this event into an aggregate chain.
783    pub fn aggregate(mut self, aggregate: AggregateRef) -> Self {
784        self.aggregate = Some(aggregate);
785        self
786    }
787
788    /// Consume the builder and produce an [`Event`] with the given `header`.
789    pub fn build(self, header: Header) -> Event {
790        Event {
791            header,
792            verb: self.verb,
793            substrate: self.substrate,
794            actor: self.actor,
795            kind: self.kind,
796            payload: self.payload,
797            payload_schema_version: self.payload_schema_version,
798            profile_state_version: self.profile_state_version,
799            aggregate: self.aggregate,
800        }
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    extern crate alloc;
807
808    use super::*;
809    use crate::{Namespace, Timestamp};
810    #[cfg(feature = "serde")]
811    use alloc::string::ToString;
812
813    fn header() -> Header {
814        Header::new(
815            Id128::from_u128(1),
816            Namespace::local(),
817            Timestamp::from_secs(1700000000),
818        )
819    }
820
821    #[test]
822    fn event_kind_parse_roundtrip() {
823        for kind in EventKind::ALL {
824            let parsed: EventKind = kind
825                .name()
826                .parse()
827                .expect("EventKind::name must parse back");
828            assert_eq!(parsed, kind);
829        }
830    }
831
832    #[test]
833    fn rerank_payload_records_served_profile() {
834        let payload = EventPayload::RerankExecuted(RerankExecutedPayload {
835            served_by_profile_id: Some("profile-a".into()),
836            model_id: Id128::from_u128(1),
837            candidates: Vec::new(),
838            reranked: Vec::new(),
839            final_scores: Vec::new(),
840            latency_us: 100,
841            hook_applied: false,
842            hook_target_match: false,
843        });
844        let event = EventBuilder::new("rerank", SubstrateKind::Note, "agent:test")
845            .kind(EventKind::RerankExecuted)
846            .payload(payload)
847            .build(header());
848
849        if let EventPayload::RerankExecuted(ref p) = event.payload {
850            assert_eq!(p.served_by_profile_id.as_deref(), Some("profile-a"));
851        } else {
852            panic!("unexpected payload variant");
853        }
854    }
855
856    #[test]
857    fn proposal_payloads_are_typed() {
858        let payload = EventPayload::ProposalReviewed(ProposalReviewedPayload {
859            proposal_id: Id128::from_u128(42),
860            reviewer: "ocean".into(),
861            decision: ProposalDecision::Approve,
862            comment: None,
863        });
864        let event = EventBuilder::new("review", SubstrateKind::Entity, "ocean")
865            .kind(EventKind::ProposalReviewed)
866            .payload(payload)
867            .build(header());
868        assert_eq!(event.kind.name(), "proposal_reviewed");
869    }
870
871    /// C1 regression: all ProposalChangeset variants that carry Id128 fields must
872    /// round-trip through serde_json::Value.  Previously `Id128::deserialize` used
873    /// `<&str>::deserialize` which fails when the deserializer holds owned data
874    /// (the Value-backed path used by the MCP DSL parser).
875    #[cfg(feature = "serde")]
876    #[test]
877    fn proposal_changeset_id_variants_deserialize_from_value() {
878        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
879        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
880
881        // UpdateEntity — patch is now a structured ProposalEntityPatch object
882        let v =
883            serde_json::json!({"kind": "update_entity", "id": uuid, "patch": {"name": "NewName"}});
884        let cs: ProposalChangeset =
885            serde_json::from_value(v).expect("UpdateEntity must deserialize from Value");
886        assert!(
887            matches!(cs, ProposalChangeset::UpdateEntity { .. }),
888            "expected UpdateEntity"
889        );
890
891        // AddEdge
892        let v = serde_json::json!({
893            "kind": "add_edge",
894            "source": uuid, "target": uuid2,
895            "relation": "extends", "weight": 1.0
896        });
897        let cs: ProposalChangeset =
898            serde_json::from_value(v).expect("AddEdge must deserialize from Value");
899        assert!(
900            matches!(cs, ProposalChangeset::AddEdge { .. }),
901            "expected AddEdge"
902        );
903
904        // MergeEntities
905        let v = serde_json::json!({"kind": "merge_entities", "into": uuid, "from": uuid2});
906        let cs: ProposalChangeset =
907            serde_json::from_value(v).expect("MergeEntities must deserialize from Value");
908        assert!(
909            matches!(cs, ProposalChangeset::MergeEntities { .. }),
910            "expected MergeEntities"
911        );
912
913        // SupersedeEntity
914        let v = serde_json::json!({"kind": "supersede_entity", "old": uuid, "new": uuid2});
915        let cs: ProposalChangeset =
916            serde_json::from_value(v).expect("SupersedeEntity must deserialize from Value");
917        assert!(
918            matches!(cs, ProposalChangeset::SupersedeEntity { .. }),
919            "expected SupersedeEntity"
920        );
921    }
922
923    #[cfg(feature = "serde")]
924    #[test]
925    fn proposal_changeset_rejects_invalid_edge_weight() {
926        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
927        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
928
929        let v = serde_json::json!({
930            "kind": "add_edge",
931            "source": uuid, "target": uuid2,
932            "relation": "extends", "weight": 2.0
933        });
934        let result: Result<ProposalChangeset, _> = serde_json::from_value(v);
935        assert!(result.is_err());
936        let err = result.unwrap_err().to_string();
937        assert!(
938            err.contains("[0.0, 1.0]"),
939            "error should mention range: {err}"
940        );
941    }
942
943    #[cfg(feature = "serde")]
944    #[test]
945    fn proposal_changeset_accepts_null_edge_weight() {
946        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
947        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
948
949        let v = serde_json::json!({
950            "kind": "add_edge",
951            "source": uuid, "target": uuid2,
952            "relation": "extends", "weight": null
953        });
954        let cs: ProposalChangeset =
955            serde_json::from_value(v).expect("null weight should be accepted");
956        assert!(matches!(
957            cs,
958            ProposalChangeset::AddEdge { weight: None, .. }
959        ));
960    }
961
962    #[cfg(feature = "serde")]
963    #[test]
964    fn rerank_payload_serde_rejects_non_finite_score() {
965        let json = serde_json::json!({
966            "served_by_profile_id": null,
967            "model_id": "00000000-0000-0000-0000-000000000001",
968            "candidates": [],
969            "reranked": [],
970            "final_scores": [["00000000-0000-0000-0000-000000000001", "Infinity"]],
971            "latency_us": 100,
972            "hook_applied": false,
973            "hook_target_match": false
974        });
975        let result: Result<RerankExecutedPayload, _> = serde_json::from_value(json);
976        assert!(result.is_err());
977    }
978
979    #[test]
980    fn rerank_payload_is_valid_checks_finite() {
981        let p = RerankExecutedPayload {
982            served_by_profile_id: None,
983            model_id: Id128::from_u128(1),
984            candidates: Vec::new(),
985            reranked: Vec::new(),
986            final_scores: alloc::vec![(Id128::from_u128(1), 0.5)],
987            latency_us: 100,
988            hook_applied: false,
989            hook_target_match: false,
990        };
991        assert!(p.is_valid());
992
993        let p_inf = RerankExecutedPayload {
994            served_by_profile_id: None,
995            model_id: Id128::from_u128(1),
996            candidates: Vec::new(),
997            reranked: Vec::new(),
998            final_scores: alloc::vec![(Id128::from_u128(1), f32::INFINITY)],
999            latency_us: 100,
1000            hook_applied: false,
1001            hook_target_match: false,
1002        };
1003        assert!(!p_inf.is_valid());
1004    }
1005}