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