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#[cfg(feature = "serde")]
397#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
398pub struct ProposalCreatedPayload {
399    pub proposal_id: Id128,
400    pub proposer: String,
401    pub title: String,
402    pub description: String,
403    pub changeset: ProposalChangeset,
404    pub reviewers: Vec<String>,
405    pub expiry: Option<crate::Timestamp>,
406    pub parent_id: Option<Id128>,
407}
408
409/// Structured draft for adding a new entity via a proposal.
410///
411/// Fields mirror the `create(kind=<entity kind>)` verb surface; `kind` is
412/// validated against the closed 8-kind entity taxonomy at apply time.
413#[cfg(feature = "serde")]
414#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
415pub struct EntityDraft {
416    /// Entity kind — must be one of the 8 closed entity kind values.
417    pub kind: String,
418    /// Human-readable name (required).
419    pub name: String,
420    /// Optional long-form description.
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub description: Option<String>,
423    /// Arbitrary structured metadata.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub properties: Option<serde_json::Value>,
426    /// Classification tags.
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub tags: Vec<String>,
429}
430
431/// Structured patch for modifying an existing entity via a proposal.
432///
433/// Absent fields mean "leave unchanged". Setting `description` to `null` clears it.
434#[cfg(feature = "serde")]
435#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
436pub struct ProposalEntityPatch {
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub name: Option<String>,
439    /// `null` clears the description; absent leaves it unchanged.
440    #[serde(
441        default,
442        skip_serializing_if = "Option::is_none",
443        with = "serde_opt_opt"
444    )]
445    pub description: Option<Option<String>>,
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub properties: Option<serde_json::Value>,
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub tags: Option<Vec<String>>,
450}
451
452/// Structured draft for adding a new note via a proposal.
453///
454/// Fields mirror the `create(kind=<note kind>)` verb surface.
455#[cfg(feature = "serde")]
456#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
457pub struct NoteDraft {
458    /// Note kind string (validated by the loaded pack at apply time).
459    pub kind: String,
460    /// Note body / content (required).
461    pub content: String,
462    /// Optional short name.
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub name: Option<String>,
465    /// Arbitrary structured metadata.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub properties: Option<serde_json::Value>,
468}
469
470/// Serde helper for `Option<Option<T>>` — distinguishes absent vs. explicit null.
471#[cfg(feature = "serde")]
472mod serde_opt_opt {
473    use serde::{Deserialize, Deserializer, Serialize, Serializer};
474
475    pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
476    where
477        T: Serialize,
478        S: Serializer,
479    {
480        match val {
481            None => unreachable!("skip_serializing_if guards the None case"),
482            Some(inner) => inner.serialize(s),
483        }
484    }
485
486    pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
487    where
488        T: Deserialize<'de>,
489        D: Deserializer<'de>,
490    {
491        let opt: Option<T> = Option::deserialize(d)?;
492        Ok(Some(opt))
493    }
494}
495
496#[cfg(feature = "serde")]
497#[derive(Clone, Debug, PartialEq, serde::Serialize)]
498#[serde(tag = "kind", rename_all = "snake_case")]
499pub enum ProposalChangeset {
500    /// Add a new entity. `entity.kind` validated at apply time.
501    AddEntity {
502        entity: EntityDraft,
503    },
504    /// Modify an existing entity's properties / tags / description.
505    UpdateEntity {
506        id: Id128,
507        patch: ProposalEntityPatch,
508    },
509    /// Add a typed edge. `weight` must be finite and in `[0.0, 1.0]` if present.
510    AddEdge {
511        source: Id128,
512        target: Id128,
513        relation: crate::EdgeRelation,
514        weight: Option<f32>,
515    },
516    /// Add a note (entity-annotating or stand-alone).
517    AddNote {
518        note: NoteDraft,
519    },
520    MergeEntities {
521        into: Id128,
522        from: Id128,
523    },
524    SupersedeEntity {
525        old: Id128,
526        new: Id128,
527    },
528    Compound {
529        steps: Vec<ProposalChangeset>,
530    },
531}
532
533#[cfg(feature = "serde")]
534impl ProposalChangeset {
535    fn validate(&self) -> Result<(), alloc::string::String> {
536        match self {
537            Self::AddEdge { weight, .. } => {
538                if let Some(w) = weight {
539                    if !w.is_finite() {
540                        return Err(alloc::format!(
541                            "ProposalChangeset AddEdge weight must be finite, got {w}"
542                        ));
543                    }
544                    if !(*w >= 0.0 && *w <= 1.0) {
545                        return Err(alloc::format!(
546                            "ProposalChangeset AddEdge weight must be in [0.0, 1.0], got {w}"
547                        ));
548                    }
549                }
550                Ok(())
551            }
552            Self::Compound { steps } => {
553                for step in steps {
554                    step.validate()?;
555                }
556                Ok(())
557            }
558            _ => Ok(()),
559        }
560    }
561}
562
563#[cfg(feature = "serde")]
564impl<'de> serde::Deserialize<'de> for ProposalChangeset {
565    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
566    where
567        D: serde::Deserializer<'de>,
568    {
569        #[derive(serde::Deserialize)]
570        #[serde(tag = "kind", rename_all = "snake_case")]
571        enum ProposalChangesetRaw {
572            AddEntity {
573                entity: EntityDraft,
574            },
575            UpdateEntity {
576                id: Id128,
577                patch: ProposalEntityPatch,
578            },
579            AddEdge {
580                source: Id128,
581                target: Id128,
582                relation: crate::EdgeRelation,
583                weight: Option<f32>,
584            },
585            AddNote {
586                note: NoteDraft,
587            },
588            MergeEntities {
589                into: Id128,
590                from: Id128,
591            },
592            SupersedeEntity {
593                old: Id128,
594                new: Id128,
595            },
596            Compound {
597                steps: Vec<ProposalChangeset>,
598            },
599        }
600
601        let raw = ProposalChangesetRaw::deserialize(deserializer)?;
602        let cs = match raw {
603            ProposalChangesetRaw::AddEntity { entity } => Self::AddEntity { entity },
604            ProposalChangesetRaw::UpdateEntity { id, patch } => Self::UpdateEntity { id, patch },
605            ProposalChangesetRaw::AddEdge {
606                source,
607                target,
608                relation,
609                weight,
610            } => Self::AddEdge {
611                source,
612                target,
613                relation,
614                weight,
615            },
616            ProposalChangesetRaw::AddNote { note } => Self::AddNote { note },
617            ProposalChangesetRaw::MergeEntities { into, from } => {
618                Self::MergeEntities { into, from }
619            }
620            ProposalChangesetRaw::SupersedeEntity { old, new } => {
621                Self::SupersedeEntity { old, new }
622            }
623            ProposalChangesetRaw::Compound { steps } => Self::Compound { steps },
624        };
625        cs.validate().map_err(serde::de::Error::custom)?;
626        Ok(cs)
627    }
628}
629
630#[cfg(not(feature = "serde"))]
631#[derive(Clone, Debug, PartialEq)]
632pub enum ProposalChangeset {
633    AddEdge {
634        source: Id128,
635        target: Id128,
636        relation: crate::EdgeRelation,
637        weight: Option<f32>,
638    },
639    MergeEntities {
640        into: Id128,
641        from: Id128,
642    },
643    SupersedeEntity {
644        old: Id128,
645        new: Id128,
646    },
647    Compound {
648        steps: Vec<ProposalChangeset>,
649    },
650}
651
652#[derive(Clone, Debug, PartialEq)]
653#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
654pub struct ProposalReviewedPayload {
655    pub proposal_id: Id128,
656    pub reviewer: String,
657    pub decision: ProposalDecision,
658    pub comment: Option<String>,
659}
660
661/// A reviewer's decision on a proposal.
662#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
663#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
664#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
665pub enum ProposalDecision {
666    /// The reviewer approved the proposal for application.
667    Approve,
668    /// The reviewer rejected the proposal; it will not be applied.
669    Reject,
670    /// The reviewer left a comment without blocking the proposal.
671    Comment,
672    /// The reviewer requested changes before the proposal can proceed.
673    RequestChanges,
674}
675
676impl ProposalDecision {
677    /// Returns the bare variant name as a lowercase string, matching the serde
678    /// `rename_all = "snake_case"` representation.  Use this when storing the
679    /// decision as a plain TEXT column — **not** `serde_json::to_string`, which
680    /// would produce a JSON-quoted string (`"\"approve\""` instead of `"approve"`).
681    pub fn as_str(self) -> &'static str {
682        match self {
683            Self::Approve => "approve",
684            Self::Reject => "reject",
685            Self::Comment => "comment",
686            Self::RequestChanges => "request_changes",
687        }
688    }
689}
690
691#[derive(Clone, Debug, PartialEq)]
692#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
693pub struct ProposalAppliedPayload {
694    pub proposal_id: Id128,
695    pub applied_at: crate::Timestamp,
696    pub applied_by: String,
697    pub result: ApplyResult,
698}
699
700#[derive(Clone, Debug, PartialEq)]
701#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
702#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
703pub enum ApplyResult {
704    Success {
705        created_records: Vec<Id128>,
706    },
707    Failed {
708        error: String,
709        applied_step_count: u32,
710    },
711}
712
713#[derive(Clone, Debug, PartialEq)]
714#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
715pub struct ProposalWithdrawnPayload {
716    pub proposal_id: Id128,
717    pub by: String,
718    pub reason: Option<String>,
719}
720
721/// Builder for events. Used by the verb dispatch path.
722pub struct EventBuilder {
723    verb: String,
724    substrate: SubstrateKind,
725    actor: Option<String>,
726    kind: EventKind,
727    payload: EventPayload,
728    payload_schema_version: u32,
729    profile_state_version: Option<u64>,
730    aggregate: Option<AggregateRef>,
731}
732
733impl EventBuilder {
734    /// Create a new builder for an event produced by `verb` acting on `substrate` as `actor`.
735    pub fn new(
736        verb: impl Into<String>,
737        substrate: SubstrateKind,
738        actor: impl Into<String>,
739    ) -> Self {
740        Self {
741            verb: verb.into(),
742            substrate,
743            actor: Some(actor.into()),
744            kind: EventKind::Audit,
745            payload: EventPayload::default(),
746            payload_schema_version: 1,
747            profile_state_version: None,
748            aggregate: None,
749        }
750    }
751
752    /// Override the event kind discriminant.
753    pub fn kind(mut self, kind: EventKind) -> Self {
754        self.kind = kind;
755        self
756    }
757
758    /// Set the typed payload for this event.
759    pub fn payload(mut self, payload: EventPayload) -> Self {
760        self.payload = payload;
761        self
762    }
763
764    /// Set the payload schema version (defaults to 1).
765    pub fn payload_schema_version(mut self, version: u32) -> Self {
766        self.payload_schema_version = version;
767        self
768    }
769
770    /// Record the brain profile state version observed at emit time.
771    pub fn profile_state_version(mut self, version: u64) -> Self {
772        self.profile_state_version = Some(version);
773        self
774    }
775
776    /// Thread this event into an aggregate chain.
777    pub fn aggregate(mut self, aggregate: AggregateRef) -> Self {
778        self.aggregate = Some(aggregate);
779        self
780    }
781
782    /// Consume the builder and produce an [`Event`] with the given `header`.
783    pub fn build(self, header: Header) -> Event {
784        Event {
785            header,
786            verb: self.verb,
787            substrate: self.substrate,
788            actor: self.actor,
789            kind: self.kind,
790            payload: self.payload,
791            payload_schema_version: self.payload_schema_version,
792            profile_state_version: self.profile_state_version,
793            aggregate: self.aggregate,
794        }
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    extern crate alloc;
801
802    use super::*;
803    use crate::{Namespace, Timestamp};
804    #[cfg(feature = "serde")]
805    use alloc::string::ToString;
806
807    fn header() -> Header {
808        Header::new(
809            Id128::from_u128(1),
810            Namespace::local(),
811            Timestamp::from_secs(1700000000),
812        )
813    }
814
815    #[test]
816    fn event_kind_parse_roundtrip() {
817        for kind in EventKind::ALL {
818            let parsed: EventKind = kind
819                .name()
820                .parse()
821                .expect("EventKind::name must parse back");
822            assert_eq!(parsed, kind);
823        }
824    }
825
826    #[test]
827    fn rerank_payload_records_served_profile() {
828        let payload = EventPayload::RerankExecuted(RerankExecutedPayload {
829            served_by_profile_id: Some("profile-a".into()),
830            model_id: Id128::from_u128(1),
831            candidates: Vec::new(),
832            reranked: Vec::new(),
833            final_scores: Vec::new(),
834            latency_us: 100,
835            hook_applied: false,
836            hook_target_match: false,
837        });
838        let event = EventBuilder::new("rerank", SubstrateKind::Note, "agent:test")
839            .kind(EventKind::RerankExecuted)
840            .payload(payload)
841            .build(header());
842
843        if let EventPayload::RerankExecuted(ref p) = event.payload {
844            assert_eq!(p.served_by_profile_id.as_deref(), Some("profile-a"));
845        } else {
846            panic!("unexpected payload variant");
847        }
848    }
849
850    #[test]
851    fn proposal_payloads_are_typed() {
852        let payload = EventPayload::ProposalReviewed(ProposalReviewedPayload {
853            proposal_id: Id128::from_u128(42),
854            reviewer: "ocean".into(),
855            decision: ProposalDecision::Approve,
856            comment: None,
857        });
858        let event = EventBuilder::new("review", SubstrateKind::Entity, "ocean")
859            .kind(EventKind::ProposalReviewed)
860            .payload(payload)
861            .build(header());
862        assert_eq!(event.kind.name(), "proposal_reviewed");
863    }
864
865    /// C1 regression: all ProposalChangeset variants that carry Id128 fields must
866    /// round-trip through serde_json::Value.  Previously `Id128::deserialize` used
867    /// `<&str>::deserialize` which fails when the deserializer holds owned data
868    /// (the Value-backed path used by the MCP DSL parser).
869    #[cfg(feature = "serde")]
870    #[test]
871    fn proposal_changeset_id_variants_deserialize_from_value() {
872        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
873        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
874
875        // UpdateEntity — patch is now a structured ProposalEntityPatch object
876        let v =
877            serde_json::json!({"kind": "update_entity", "id": uuid, "patch": {"name": "NewName"}});
878        let cs: ProposalChangeset =
879            serde_json::from_value(v).expect("UpdateEntity must deserialize from Value");
880        assert!(
881            matches!(cs, ProposalChangeset::UpdateEntity { .. }),
882            "expected UpdateEntity"
883        );
884
885        // AddEdge
886        let v = serde_json::json!({
887            "kind": "add_edge",
888            "source": uuid, "target": uuid2,
889            "relation": "extends", "weight": 1.0
890        });
891        let cs: ProposalChangeset =
892            serde_json::from_value(v).expect("AddEdge must deserialize from Value");
893        assert!(
894            matches!(cs, ProposalChangeset::AddEdge { .. }),
895            "expected AddEdge"
896        );
897
898        // MergeEntities
899        let v = serde_json::json!({"kind": "merge_entities", "into": uuid, "from": uuid2});
900        let cs: ProposalChangeset =
901            serde_json::from_value(v).expect("MergeEntities must deserialize from Value");
902        assert!(
903            matches!(cs, ProposalChangeset::MergeEntities { .. }),
904            "expected MergeEntities"
905        );
906
907        // SupersedeEntity
908        let v = serde_json::json!({"kind": "supersede_entity", "old": uuid, "new": uuid2});
909        let cs: ProposalChangeset =
910            serde_json::from_value(v).expect("SupersedeEntity must deserialize from Value");
911        assert!(
912            matches!(cs, ProposalChangeset::SupersedeEntity { .. }),
913            "expected SupersedeEntity"
914        );
915    }
916
917    #[cfg(feature = "serde")]
918    #[test]
919    fn proposal_changeset_rejects_invalid_edge_weight() {
920        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
921        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
922
923        let v = serde_json::json!({
924            "kind": "add_edge",
925            "source": uuid, "target": uuid2,
926            "relation": "extends", "weight": 2.0
927        });
928        let result: Result<ProposalChangeset, _> = serde_json::from_value(v);
929        assert!(result.is_err());
930        let err = result.unwrap_err().to_string();
931        assert!(
932            err.contains("[0.0, 1.0]"),
933            "error should mention range: {err}"
934        );
935    }
936
937    #[cfg(feature = "serde")]
938    #[test]
939    fn proposal_changeset_accepts_null_edge_weight() {
940        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
941        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
942
943        let v = serde_json::json!({
944            "kind": "add_edge",
945            "source": uuid, "target": uuid2,
946            "relation": "extends", "weight": null
947        });
948        let cs: ProposalChangeset =
949            serde_json::from_value(v).expect("null weight should be accepted");
950        assert!(matches!(
951            cs,
952            ProposalChangeset::AddEdge { weight: None, .. }
953        ));
954    }
955
956    #[cfg(feature = "serde")]
957    #[test]
958    fn rerank_payload_serde_rejects_non_finite_score() {
959        let json = serde_json::json!({
960            "served_by_profile_id": null,
961            "model_id": "00000000-0000-0000-0000-000000000001",
962            "candidates": [],
963            "reranked": [],
964            "final_scores": [["00000000-0000-0000-0000-000000000001", "Infinity"]],
965            "latency_us": 100,
966            "hook_applied": false,
967            "hook_target_match": false
968        });
969        let result: Result<RerankExecutedPayload, _> = serde_json::from_value(json);
970        assert!(result.is_err());
971    }
972
973    #[test]
974    fn rerank_payload_is_valid_checks_finite() {
975        let p = RerankExecutedPayload {
976            served_by_profile_id: None,
977            model_id: Id128::from_u128(1),
978            candidates: Vec::new(),
979            reranked: Vec::new(),
980            final_scores: alloc::vec![(Id128::from_u128(1), 0.5)],
981            latency_us: 100,
982            hook_applied: false,
983            hook_target_match: false,
984        };
985        assert!(p.is_valid());
986
987        let p_inf = RerankExecutedPayload {
988            served_by_profile_id: None,
989            model_id: Id128::from_u128(1),
990            candidates: Vec::new(),
991            reranked: Vec::new(),
992            final_scores: alloc::vec![(Id128::from_u128(1), f32::INFINITY)],
993            latency_us: 100,
994            hook_applied: false,
995            hook_target_match: false,
996        };
997        assert!(!p_inf.is_valid());
998    }
999}