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