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