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