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