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` or
515/// `entity_type` to `null` explicitly clears it; a string `entity_type` sets
516/// it, validated against the kind's registered vocabulary at apply time.
517#[cfg(feature = "serde")]
518#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
519pub struct ProposalEntityPatch {
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub name: Option<String>,
522    /// `null` clears the description; absent leaves it unchanged.
523    #[serde(
524        default,
525        skip_serializing_if = "Option::is_none",
526        with = "serde_opt_opt"
527    )]
528    pub description: Option<Option<String>>,
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub properties: Option<serde_json::Value>,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub tags: Option<Vec<String>>,
533    /// ADR-014 tri-state: absent leaves the type unchanged, `null` explicitly
534    /// clears it, a string sets it (validated against the kind's vocabulary
535    /// at apply time, per ADR-046 parity with the runtime entity update).
536    #[serde(
537        default,
538        skip_serializing_if = "Option::is_none",
539        with = "serde_opt_opt"
540    )]
541    pub entity_type: Option<Option<String>>,
542}
543
544/// Structured draft for adding a new note via a proposal.
545///
546/// Fields mirror the `create(kind=<note kind>)` verb surface.
547#[cfg(feature = "serde")]
548#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
549pub struct NoteDraft {
550    /// Note kind string (validated by the loaded pack at apply time).
551    pub kind: String,
552    /// Note body / content (required).
553    pub content: String,
554    /// Optional short name.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub name: Option<String>,
557    /// Arbitrary structured metadata.
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub properties: Option<serde_json::Value>,
560}
561
562/// Serde helper for `Option<Option<T>>` — distinguishes absent vs. explicit null.
563#[cfg(feature = "serde")]
564mod serde_opt_opt {
565    use serde::{Deserialize, Deserializer, Serialize, Serializer};
566
567    pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
568    where
569        T: Serialize,
570        S: Serializer,
571    {
572        match val {
573            None => unreachable!("skip_serializing_if guards the None case"),
574            Some(inner) => inner.serialize(s),
575        }
576    }
577
578    pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
579    where
580        T: Deserialize<'de>,
581        D: Deserializer<'de>,
582    {
583        let opt: Option<T> = Option::deserialize(d)?;
584        Ok(Some(opt))
585    }
586}
587
588/// The set of KG mutations a proposal intends to apply as a proposal changeset.
589#[cfg(feature = "serde")]
590#[derive(Clone, Debug, PartialEq, serde::Serialize)]
591#[serde(tag = "kind", rename_all = "snake_case")]
592pub enum ProposalChangeset {
593    /// Add a new entity. `entity.kind` validated at apply time.
594    AddEntity {
595        entity: EntityDraft,
596    },
597    /// Modify an existing entity's properties / tags / description / entity
598    /// type (absent = unchanged, null = clear, string = set-and-validate).
599    UpdateEntity {
600        id: Id128,
601        patch: ProposalEntityPatch,
602    },
603    /// Add a typed edge. `weight` must be finite and in `[0.0, 1.0]` if present.
604    AddEdge {
605        source: Id128,
606        target: Id128,
607        relation: crate::EdgeRelation,
608        weight: Option<f32>,
609    },
610    /// Add a note (entity-annotating or stand-alone).
611    AddNote {
612        note: NoteDraft,
613    },
614    MergeEntities {
615        into: Id128,
616        from: Id128,
617    },
618    SupersedeEntity {
619        old: Id128,
620        new: Id128,
621    },
622    Compound {
623        steps: Vec<ProposalChangeset>,
624    },
625}
626
627#[cfg(feature = "serde")]
628impl ProposalChangeset {
629    fn validate(&self) -> Result<(), alloc::string::String> {
630        match self {
631            Self::AddEdge { weight, .. } => {
632                if let Some(w) = weight {
633                    if !w.is_finite() {
634                        return Err(alloc::format!(
635                            "ProposalChangeset AddEdge weight must be finite, got {w}"
636                        ));
637                    }
638                    if !(*w >= 0.0 && *w <= 1.0) {
639                        return Err(alloc::format!(
640                            "ProposalChangeset AddEdge weight must be in [0.0, 1.0], got {w}"
641                        ));
642                    }
643                }
644                Ok(())
645            }
646            Self::Compound { steps } => {
647                for step in steps {
648                    step.validate()?;
649                }
650                Ok(())
651            }
652            _ => Ok(()),
653        }
654    }
655}
656
657#[cfg(feature = "serde")]
658impl<'de> serde::Deserialize<'de> for ProposalChangeset {
659    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
660    where
661        D: serde::Deserializer<'de>,
662    {
663        #[derive(serde::Deserialize)]
664        #[serde(tag = "kind", rename_all = "snake_case")]
665        enum ProposalChangesetRaw {
666            AddEntity {
667                entity: EntityDraft,
668            },
669            UpdateEntity {
670                id: Id128,
671                patch: ProposalEntityPatch,
672            },
673            AddEdge {
674                source: Id128,
675                target: Id128,
676                relation: crate::EdgeRelation,
677                weight: Option<f32>,
678            },
679            AddNote {
680                note: NoteDraft,
681            },
682            MergeEntities {
683                into: Id128,
684                from: Id128,
685            },
686            SupersedeEntity {
687                old: Id128,
688                new: Id128,
689            },
690            Compound {
691                steps: Vec<ProposalChangeset>,
692            },
693        }
694
695        let raw = ProposalChangesetRaw::deserialize(deserializer)?;
696        let cs = match raw {
697            ProposalChangesetRaw::AddEntity { entity } => Self::AddEntity { entity },
698            ProposalChangesetRaw::UpdateEntity { id, patch } => Self::UpdateEntity { id, patch },
699            ProposalChangesetRaw::AddEdge {
700                source,
701                target,
702                relation,
703                weight,
704            } => Self::AddEdge {
705                source,
706                target,
707                relation,
708                weight,
709            },
710            ProposalChangesetRaw::AddNote { note } => Self::AddNote { note },
711            ProposalChangesetRaw::MergeEntities { into, from } => {
712                Self::MergeEntities { into, from }
713            }
714            ProposalChangesetRaw::SupersedeEntity { old, new } => {
715                Self::SupersedeEntity { old, new }
716            }
717            ProposalChangesetRaw::Compound { steps } => Self::Compound { steps },
718        };
719        cs.validate().map_err(serde::de::Error::custom)?;
720        Ok(cs)
721    }
722}
723
724#[cfg(not(feature = "serde"))]
725#[derive(Clone, Debug, PartialEq)]
726pub enum ProposalChangeset {
727    AddEdge {
728        source: Id128,
729        target: Id128,
730        relation: crate::EdgeRelation,
731        weight: Option<f32>,
732    },
733    MergeEntities {
734        into: Id128,
735        from: Id128,
736    },
737    SupersedeEntity {
738        old: Id128,
739        new: Id128,
740    },
741    Compound {
742        steps: Vec<ProposalChangeset>,
743    },
744}
745
746/// Payload for the `ProposalReviewed` event — records a single reviewer's decision.
747#[derive(Clone, Debug, PartialEq)]
748#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
749pub struct ProposalReviewedPayload {
750    pub proposal_id: Id128,
751    pub reviewer: String,
752    pub decision: ProposalDecision,
753    pub comment: Option<String>,
754}
755
756/// A reviewer's decision on a proposal.
757#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
758#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
759#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
760pub enum ProposalDecision {
761    /// The reviewer approved the proposal for application.
762    Approve,
763    /// The reviewer rejected the proposal; it will not be applied.
764    Reject,
765    /// The reviewer left a comment without blocking the proposal.
766    Comment,
767    /// The reviewer requested changes before the proposal can proceed.
768    RequestChanges,
769}
770
771impl ProposalDecision {
772    /// Returns the bare variant name as a lowercase string, matching the serde
773    /// `rename_all = "snake_case"` representation.  Use this when storing the
774    /// decision as a plain TEXT column — **not** `serde_json::to_string`, which
775    /// would produce a JSON-quoted string (`"\"approve\""` instead of `"approve"`).
776    pub fn as_str(self) -> &'static str {
777        match self {
778            Self::Approve => "approve",
779            Self::Reject => "reject",
780            Self::Comment => "comment",
781            Self::RequestChanges => "request_changes",
782        }
783    }
784}
785
786/// Payload for the `ProposalApplied` event — records the outcome of the apply attempt.
787#[derive(Clone, Debug, PartialEq)]
788#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
789pub struct ProposalAppliedPayload {
790    pub proposal_id: Id128,
791    pub applied_at: crate::Timestamp,
792    pub applied_by: String,
793    pub result: ApplyResult,
794}
795
796/// Outcome of applying a proposal: either all steps succeeded or the apply failed with an error.
797#[derive(Clone, Debug, PartialEq)]
798#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
799#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
800pub enum ApplyResult {
801    Success {
802        created_records: Vec<Id128>,
803    },
804    Failed {
805        error: String,
806        applied_step_count: u32,
807    },
808}
809
810/// Payload for the `ProposalWithdrawn` event — records who withdrew and an optional reason.
811#[derive(Clone, Debug, PartialEq)]
812#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
813pub struct ProposalWithdrawnPayload {
814    pub proposal_id: Id128,
815    pub by: String,
816    pub reason: Option<String>,
817}
818
819/// Builder for events. Used by the verb dispatch path.
820pub struct EventBuilder {
821    verb: String,
822    substrate: SubstrateKind,
823    actor: Option<String>,
824    kind: EventKind,
825    payload: EventPayload,
826    payload_schema_version: u32,
827    profile_state_version: Option<u64>,
828    aggregate: Option<AggregateRef>,
829}
830
831impl EventBuilder {
832    /// Create a new builder for an event produced by `verb` acting on `substrate` as `actor`.
833    pub fn new(
834        verb: impl Into<String>,
835        substrate: SubstrateKind,
836        actor: impl Into<String>,
837    ) -> Self {
838        Self {
839            verb: verb.into(),
840            substrate,
841            actor: Some(actor.into()),
842            kind: EventKind::Audit,
843            payload: EventPayload::default(),
844            payload_schema_version: 1,
845            profile_state_version: None,
846            aggregate: None,
847        }
848    }
849
850    /// Override the event kind discriminant.
851    pub fn kind(mut self, kind: EventKind) -> Self {
852        self.kind = kind;
853        self
854    }
855
856    /// Set the typed payload for this event.
857    pub fn payload(mut self, payload: EventPayload) -> Self {
858        self.payload = payload;
859        self
860    }
861
862    /// Set the payload schema version (defaults to 1).
863    pub fn payload_schema_version(mut self, version: u32) -> Self {
864        self.payload_schema_version = version;
865        self
866    }
867
868    /// Record the brain profile state version observed at emit time.
869    pub fn profile_state_version(mut self, version: u64) -> Self {
870        self.profile_state_version = Some(version);
871        self
872    }
873
874    /// Thread this event into an aggregate chain.
875    pub fn aggregate(mut self, aggregate: AggregateRef) -> Self {
876        self.aggregate = Some(aggregate);
877        self
878    }
879
880    /// Consume the builder and produce an [`Event`] with the given `header`.
881    pub fn build(self, header: Header) -> Event {
882        Event {
883            header,
884            verb: self.verb,
885            substrate: self.substrate,
886            actor: self.actor,
887            kind: self.kind,
888            payload: self.payload,
889            payload_schema_version: self.payload_schema_version,
890            profile_state_version: self.profile_state_version,
891            aggregate: self.aggregate,
892        }
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    extern crate alloc;
899
900    use super::*;
901    use crate::{Namespace, Timestamp};
902    #[cfg(feature = "serde")]
903    use alloc::string::ToString;
904
905    fn header() -> Header {
906        Header::new(
907            Id128::from_u128(1),
908            Namespace::local(),
909            Timestamp::from_secs(1700000000),
910        )
911    }
912
913    #[test]
914    fn event_kind_parse_roundtrip() {
915        for kind in EventKind::ALL {
916            let parsed: EventKind = kind
917                .name()
918                .parse()
919                .expect("EventKind::name must parse back");
920            assert_eq!(parsed, kind);
921        }
922    }
923
924    #[test]
925    fn rerank_payload_records_served_profile() {
926        let payload = EventPayload::RerankExecuted(RerankExecutedPayload {
927            served_by_profile_id: Some("profile-a".into()),
928            model_id: Id128::from_u128(1),
929            candidates: Vec::new(),
930            reranked: Vec::new(),
931            final_scores: Vec::new(),
932            latency_us: 100,
933            hook_applied: false,
934            hook_target_match: false,
935        });
936        let event = EventBuilder::new("rerank", SubstrateKind::Note, "agent:test")
937            .kind(EventKind::RerankExecuted)
938            .payload(payload)
939            .build(header());
940
941        if let EventPayload::RerankExecuted(ref p) = event.payload {
942            assert_eq!(p.served_by_profile_id.as_deref(), Some("profile-a"));
943        } else {
944            panic!("unexpected payload variant");
945        }
946    }
947
948    #[test]
949    fn proposal_payloads_are_typed() {
950        let payload = EventPayload::ProposalReviewed(ProposalReviewedPayload {
951            proposal_id: Id128::from_u128(42),
952            reviewer: "operator".into(),
953            decision: ProposalDecision::Approve,
954            comment: None,
955        });
956        let event = EventBuilder::new("review", SubstrateKind::Entity, "operator")
957            .kind(EventKind::ProposalReviewed)
958            .payload(payload)
959            .build(header());
960        assert_eq!(event.kind.name(), "proposal_reviewed");
961    }
962
963    /// C1 regression: all ProposalChangeset variants that carry Id128 fields must
964    /// round-trip through serde_json::Value.  Previously `Id128::deserialize` used
965    /// `<&str>::deserialize` which fails when the deserializer holds owned data
966    /// (the Value-backed path used by the MCP DSL parser).
967    #[cfg(feature = "serde")]
968    #[test]
969    fn proposal_changeset_id_variants_deserialize_from_value() {
970        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
971        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
972
973        // UpdateEntity — patch is now a structured ProposalEntityPatch object
974        let v =
975            serde_json::json!({"kind": "update_entity", "id": uuid, "patch": {"name": "NewName"}});
976        let cs: ProposalChangeset =
977            serde_json::from_value(v).expect("UpdateEntity must deserialize from Value");
978        assert!(
979            matches!(cs, ProposalChangeset::UpdateEntity { .. }),
980            "expected UpdateEntity"
981        );
982
983        // Tri-state entity_type (ADR-014): absent vs explicit null vs set
984        // must all survive the proposal wire boundary distinctly.
985        for (json, expected) in [
986            (serde_json::json!({}), None),
987            (serde_json::json!({"entity_type": null}), Some(None)),
988            (
989                serde_json::json!({"entity_type": "algorithm"}),
990                Some(Some("algorithm".to_string())),
991            ),
992        ] {
993            let v = serde_json::json!({"kind": "update_entity", "id": uuid, "patch": json});
994            let cs: ProposalChangeset =
995                serde_json::from_value(v).expect("UpdateEntity must deserialize");
996            let ProposalChangeset::UpdateEntity { patch, .. } = cs else {
997                panic!("expected UpdateEntity");
998            };
999            assert_eq!(patch.entity_type, expected, "patch: {json}");
1000        }
1001
1002        // The clear must serialize back as an explicit null.
1003        let patch = ProposalEntityPatch {
1004            name: None,
1005            description: None,
1006            properties: None,
1007            tags: None,
1008            entity_type: Some(None),
1009        };
1010        let v = serde_json::to_value(&patch).expect("serialize");
1011        assert_eq!(v.get("entity_type"), Some(&serde_json::Value::Null));
1012        assert!(
1013            v.get("name").is_none(),
1014            "absent fields must not be serialized"
1015        );
1016
1017        // AddEdge
1018        let v = serde_json::json!({
1019            "kind": "add_edge",
1020            "source": uuid, "target": uuid2,
1021            "relation": "extends", "weight": 1.0
1022        });
1023        let cs: ProposalChangeset =
1024            serde_json::from_value(v).expect("AddEdge must deserialize from Value");
1025        assert!(
1026            matches!(cs, ProposalChangeset::AddEdge { .. }),
1027            "expected AddEdge"
1028        );
1029
1030        // MergeEntities
1031        let v = serde_json::json!({"kind": "merge_entities", "into": uuid, "from": uuid2});
1032        let cs: ProposalChangeset =
1033            serde_json::from_value(v).expect("MergeEntities must deserialize from Value");
1034        assert!(
1035            matches!(cs, ProposalChangeset::MergeEntities { .. }),
1036            "expected MergeEntities"
1037        );
1038
1039        // SupersedeEntity
1040        let v = serde_json::json!({"kind": "supersede_entity", "old": uuid, "new": uuid2});
1041        let cs: ProposalChangeset =
1042            serde_json::from_value(v).expect("SupersedeEntity must deserialize from Value");
1043        assert!(
1044            matches!(cs, ProposalChangeset::SupersedeEntity { .. }),
1045            "expected SupersedeEntity"
1046        );
1047    }
1048
1049    #[cfg(feature = "serde")]
1050    #[test]
1051    fn proposal_changeset_rejects_invalid_edge_weight() {
1052        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
1053        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
1054
1055        let v = serde_json::json!({
1056            "kind": "add_edge",
1057            "source": uuid, "target": uuid2,
1058            "relation": "extends", "weight": 2.0
1059        });
1060        let result: Result<ProposalChangeset, _> = serde_json::from_value(v);
1061        assert!(result.is_err());
1062        let err = result.unwrap_err().to_string();
1063        assert!(
1064            err.contains("[0.0, 1.0]"),
1065            "error should mention range: {err}"
1066        );
1067    }
1068
1069    #[cfg(feature = "serde")]
1070    #[test]
1071    fn proposal_changeset_accepts_null_edge_weight() {
1072        let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
1073        let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
1074
1075        let v = serde_json::json!({
1076            "kind": "add_edge",
1077            "source": uuid, "target": uuid2,
1078            "relation": "extends", "weight": null
1079        });
1080        let cs: ProposalChangeset =
1081            serde_json::from_value(v).expect("null weight should be accepted");
1082        assert!(matches!(
1083            cs,
1084            ProposalChangeset::AddEdge { weight: None, .. }
1085        ));
1086    }
1087
1088    #[cfg(feature = "serde")]
1089    #[test]
1090    fn rerank_payload_serde_rejects_non_finite_score() {
1091        let json = serde_json::json!({
1092            "served_by_profile_id": null,
1093            "model_id": "00000000-0000-0000-0000-000000000001",
1094            "candidates": [],
1095            "reranked": [],
1096            "final_scores": [["00000000-0000-0000-0000-000000000001", "Infinity"]],
1097            "latency_us": 100,
1098            "hook_applied": false,
1099            "hook_target_match": false
1100        });
1101        let result: Result<RerankExecutedPayload, _> = serde_json::from_value(json);
1102        assert!(result.is_err());
1103    }
1104
1105    #[test]
1106    fn rerank_payload_is_valid_checks_finite() {
1107        let p = RerankExecutedPayload {
1108            served_by_profile_id: None,
1109            model_id: Id128::from_u128(1),
1110            candidates: Vec::new(),
1111            reranked: Vec::new(),
1112            final_scores: alloc::vec![(Id128::from_u128(1), 0.5)],
1113            latency_us: 100,
1114            hook_applied: false,
1115            hook_target_match: false,
1116        };
1117        assert!(p.is_valid());
1118
1119        let p_inf = RerankExecutedPayload {
1120            served_by_profile_id: None,
1121            model_id: Id128::from_u128(1),
1122            candidates: Vec::new(),
1123            reranked: Vec::new(),
1124            final_scores: alloc::vec![(Id128::from_u128(1), f32::INFINITY)],
1125            latency_us: 100,
1126            hook_applied: false,
1127            hook_target_match: false,
1128        };
1129        assert!(!p_inf.is_valid());
1130    }
1131}