Skip to main content

falsegreen_agent/
event.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11
12use crate::genui::{ActionKind, ActionSourceType, action_kind_source_compatible};
13
14#[derive(Debug, Error)]
15pub enum EventError {
16    #[error("event-store I/O error: {0}")]
17    Io(#[from] std::io::Error),
18    #[error("SQLite event-store error: {0}")]
19    Sqlite(#[from] rusqlite::Error),
20    #[error("invalid event kind in durable history: {0}")]
21    InvalidKind(String),
22    #[error("invalid event JSON in durable history: {0}")]
23    InvalidJson(#[from] serde_json::Error),
24    #[error("system clock is before the Unix epoch")]
25    InvalidClock,
26    #[error("terminal session {0} is immutable")]
27    TerminalSessionImmutable(String),
28    #[error("predecessor session history changed while creating its replacement")]
29    PredecessorHistoryChanged,
30    #[error("session {predecessor} already has replacement {replacement}")]
31    ReplacementAlreadyExists {
32        predecessor: String,
33        replacement: String,
34    },
35    #[error("replacement session chains are not supported")]
36    ReplacementChainUnsupported,
37    #[error("malformed durable replacement provenance: {0}")]
38    MalformedReplacement(String),
39    #[error("GenUI action {0} is not confirmed")]
40    GenUiActionNotConfirmed(String),
41    #[error("GenUI action {0} has already started or completed")]
42    GenUiActionAlreadyStarted(String),
43    #[error("GenUI action {0} has an unknown external outcome")]
44    GenUiActionOutcomeUnknown(String),
45    #[error("GenUI lifecycle events must use the typed transition API")]
46    GenUiEventMustUseLifecycle,
47    #[error("invalid GenUI lifecycle event: {0}")]
48    InvalidGenUiEvent(String),
49    #[error("illegal GenUI lifecycle transition for action {action}: {from} -> {to}")]
50    GenUiIllegalTransition {
51        action: String,
52        from: String,
53        to: String,
54    },
55    #[error("trusted GenUI state generation cannot rewind")]
56    GenUiStateGenerationRewind,
57    #[error("trusted GenUI state identity changed without advancing generation")]
58    GenUiStateIdentityChanged,
59}
60
61pub(crate) const GENUI_EVENT_SCHEMA_VERSION: u16 = 1;
62
63/// Strict, versioned payload shared by every durable GenUI lifecycle event.
64/// Result/reconciliation fields are intentionally optional because they only
65/// apply to terminal outcomes.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub(crate) struct GenUiActionEventPayload {
69    pub schema_version: u16,
70    pub action_id: String,
71    pub action_kind: String,
72    /// Explicit executable transport identity. Older durable events omitted
73    /// this V9 field; they are read as MCP for compatibility, while all new
74    /// bindings persist the authoritative source selected by the catalog.
75    #[serde(default = "default_genui_source_type")]
76    pub source_type: String,
77    pub label_digest: String,
78    pub surface_id: String,
79    pub surface_digest: String,
80    pub state_digest: String,
81    pub state_generation: u64,
82    pub session_id: String,
83    pub principal: String,
84    pub authorization_context: String,
85    pub provider_id: String,
86    pub server_id: String,
87    pub tool_name: String,
88    pub remote_tool_name: String,
89    pub schema_digest: String,
90    pub policy_version: u64,
91    pub policy_digest: String,
92    pub requires_confirmation: bool,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub payload_digest: Option<String>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub result_digest: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub ok: Option<bool>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub reason: Option<String>,
101}
102
103impl GenUiActionEventPayload {
104    fn authority_eq(&self, other: &Self) -> bool {
105        self.schema_version == other.schema_version
106            && self.action_id == other.action_id
107            && self.action_kind == other.action_kind
108            && self.source_type == other.source_type
109            && self.label_digest == other.label_digest
110            && self.surface_id == other.surface_id
111            && self.surface_digest == other.surface_digest
112            && self.state_digest == other.state_digest
113            && self.state_generation == other.state_generation
114            && self.session_id == other.session_id
115            && self.principal == other.principal
116            && self.authorization_context == other.authorization_context
117            && self.provider_id == other.provider_id
118            && self.server_id == other.server_id
119            && self.tool_name == other.tool_name
120            && self.remote_tool_name == other.remote_tool_name
121            && self.schema_digest == other.schema_digest
122            && self.policy_version == other.policy_version
123            && self.policy_digest == other.policy_digest
124            && self.requires_confirmation == other.requires_confirmation
125    }
126
127    fn binding_eq(&self, other: &Self) -> bool {
128        self.authority_eq(other) && self.payload_digest == other.payload_digest
129    }
130}
131
132fn default_genui_source_type() -> String {
133    "mcp".to_owned()
134}
135
136fn parse_genui_action_kind(value: &str) -> Option<ActionKind> {
137    serde_json::from_value(Value::String(value.to_owned())).ok()
138}
139
140fn parse_genui_source_type(value: &str) -> Option<ActionSourceType> {
141    serde_json::from_value(Value::String(value.to_owned())).ok()
142}
143
144type GenUiActionState = (EventKind, GenUiActionEventPayload);
145
146fn validate_sha256_digest(label: &str, value: &str) -> Result<(), EventError> {
147    if value.len() != 64
148        || !value
149            .bytes()
150            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
151    {
152        return Err(EventError::InvalidGenUiEvent(format!(
153            "{label} must be an exact SHA-256 hex digest"
154        )));
155    }
156    Ok(())
157}
158
159fn validate_genui_payload(
160    session_id: &str,
161    kind: EventKind,
162    payload: &GenUiActionEventPayload,
163) -> Result<(), EventError> {
164    if !kind.is_genui()
165        || payload.schema_version != GENUI_EVENT_SCHEMA_VERSION
166        || payload.session_id != session_id
167        || payload.action_id.is_empty()
168    {
169        return Err(EventError::InvalidGenUiEvent(
170            "schema version, session, or action identity is invalid".to_owned(),
171        ));
172    }
173    for (label, value) in [
174        ("action_id", &payload.action_id),
175        ("action_kind", &payload.action_kind),
176        ("label_digest", &payload.label_digest),
177        ("surface_id", &payload.surface_id),
178        ("state_digest", &payload.state_digest),
179        ("session_id", &payload.session_id),
180        ("principal", &payload.principal),
181        ("authorization_context", &payload.authorization_context),
182        ("provider_id", &payload.provider_id),
183        ("server_id", &payload.server_id),
184        ("tool_name", &payload.tool_name),
185        ("remote_tool_name", &payload.remote_tool_name),
186    ] {
187        if value.is_empty() {
188            return Err(EventError::InvalidGenUiEvent(format!(
189                "{label} must not be empty"
190            )));
191        }
192    }
193    if !matches!(
194        payload.action_kind.as_str(),
195        "local_presentation" | "form_value_update" | "navigation" | "mcp_tool" | "consequential"
196    ) {
197        return Err(EventError::InvalidGenUiEvent(
198            "action_kind is not in the declared protocol subset".to_owned(),
199        ));
200    }
201    if !matches!(payload.source_type.as_str(), "mcp" | "host_local") {
202        return Err(EventError::InvalidGenUiEvent(
203            "source_type is not in the declared protocol subset".to_owned(),
204        ));
205    }
206    let action_kind = parse_genui_action_kind(&payload.action_kind).ok_or_else(|| {
207        EventError::InvalidGenUiEvent("action_kind is not a known executable kind".to_owned())
208    })?;
209    let source_type = parse_genui_source_type(&payload.source_type).ok_or_else(|| {
210        EventError::InvalidGenUiEvent("source_type is not a known executable source".to_owned())
211    })?;
212    if !action_kind_source_compatible(action_kind, source_type) {
213        return Err(EventError::InvalidGenUiEvent(format!(
214            "invalid action kind/source type pair: {action_kind:?}/{source_type:?}"
215        )));
216    }
217    if payload.requires_confirmation != (action_kind == ActionKind::Consequential) {
218        return Err(EventError::InvalidGenUiEvent(
219            "confirmation requirement does not match action kind".to_owned(),
220        ));
221    }
222    validate_sha256_digest("surface_digest", &payload.surface_digest)?;
223    validate_sha256_digest("schema_digest", &payload.schema_digest)?;
224    validate_sha256_digest("policy_digest", &payload.policy_digest)?;
225    if let Some(value) = &payload.payload_digest {
226        validate_sha256_digest("payload_digest", value)?;
227    }
228    if let Some(value) = &payload.result_digest {
229        validate_sha256_digest("result_digest", value)?;
230    }
231    match kind {
232        EventKind::GenUiActionPresented => {
233            if payload.payload_digest.is_some()
234                || payload.result_digest.is_some()
235                || payload.ok.is_some()
236                || payload.reason.is_some()
237            {
238                return Err(EventError::InvalidGenUiEvent(
239                    "presented event cannot carry a payload or outcome".to_owned(),
240                ));
241            }
242        }
243        EventKind::GenUiActionConfirmationRequested => {
244            if !payload.requires_confirmation
245                || payload.payload_digest.is_none()
246                || payload.result_digest.is_some()
247                || payload.ok.is_some()
248                || payload.reason.is_some()
249            {
250                return Err(EventError::InvalidGenUiEvent(
251                    "confirmation request must carry only a consequential payload binding"
252                        .to_owned(),
253                ));
254            }
255        }
256        EventKind::GenUiActionConfirmed => {
257            if !payload.requires_confirmation
258                || payload.payload_digest.is_none()
259                || payload.result_digest.is_some()
260                || payload.ok.is_some()
261                || payload.reason.is_some()
262            {
263                return Err(EventError::InvalidGenUiEvent(
264                    "confirmation must carry only a consequential payload binding".to_owned(),
265                ));
266            }
267        }
268        EventKind::GenUiActionExecutionStarted => {
269            if payload.payload_digest.is_none()
270                || payload.result_digest.is_some()
271                || payload.ok.is_some()
272                || payload.reason.is_some()
273            {
274                return Err(EventError::InvalidGenUiEvent(
275                    "execution start must carry an exact payload binding".to_owned(),
276                ));
277            }
278        }
279        EventKind::GenUiActionExecutionCompleted => {
280            if payload.payload_digest.is_none()
281                || payload.ok != Some(true)
282                || payload.result_digest.is_none()
283                || payload.reason.is_some()
284            {
285                return Err(EventError::InvalidGenUiEvent(
286                    "completed event must carry ok=true and an exact result digest".to_owned(),
287                ));
288            }
289        }
290        EventKind::GenUiActionExecutionFailed => {
291            if payload.payload_digest.is_none()
292                || payload.ok != Some(false)
293                || payload.result_digest.is_none()
294                || payload.reason.is_some()
295            {
296                return Err(EventError::InvalidGenUiEvent(
297                    "failed event must carry ok=false and an exact result digest".to_owned(),
298                ));
299            }
300        }
301        EventKind::GenUiActionExecutionUnknown => {
302            if payload.payload_digest.is_none()
303                || payload.reason.as_deref().is_none_or(str::is_empty)
304                || payload.result_digest.is_some()
305                || payload.ok.is_some()
306            {
307                return Err(EventError::InvalidGenUiEvent(
308                    "unknown event must carry only a payload binding and reconciliation reason"
309                        .to_owned(),
310                ));
311            }
312        }
313        _ => unreachable!("validate_genui_payload only receives GenUI events"),
314    }
315    Ok(())
316}
317
318fn legal_genui_transition(
319    previous: Option<&GenUiActionState>,
320    kind: EventKind,
321    payload: &GenUiActionEventPayload,
322) -> bool {
323    match previous {
324        None => kind == EventKind::GenUiActionPresented,
325        Some((previous_kind, previous_payload)) => match (*previous_kind, kind) {
326            (EventKind::GenUiActionPresented, EventKind::GenUiActionConfirmationRequested) => {
327                payload.requires_confirmation && payload.payload_digest.is_some()
328            }
329            (EventKind::GenUiActionConfirmationRequested, EventKind::GenUiActionConfirmed) => {
330                previous_payload.binding_eq(payload)
331            }
332            (EventKind::GenUiActionPresented, EventKind::GenUiActionExecutionStarted) => {
333                !payload.requires_confirmation && payload.payload_digest.is_some()
334            }
335            (EventKind::GenUiActionConfirmed, EventKind::GenUiActionExecutionStarted) => {
336                previous_payload.binding_eq(payload)
337            }
338            (
339                EventKind::GenUiActionExecutionStarted,
340                EventKind::GenUiActionExecutionCompleted
341                | EventKind::GenUiActionExecutionFailed
342                | EventKind::GenUiActionExecutionUnknown,
343            ) => previous_payload.binding_eq(payload),
344            _ => false,
345        },
346    }
347}
348
349fn replay_genui_history(
350    events: &[Event],
351    session_id: &str,
352) -> Result<BTreeMap<String, GenUiActionState>, EventError> {
353    let mut states: BTreeMap<String, GenUiActionState> = BTreeMap::new();
354    for event in events.iter().filter(|event| event.kind.is_genui()) {
355        if event.session_id != session_id {
356            return Err(EventError::InvalidGenUiEvent(
357                "lifecycle event belongs to another session".to_owned(),
358            ));
359        }
360        let payload = serde_json::from_value::<GenUiActionEventPayload>(event.payload.clone())
361            .map_err(|error| EventError::InvalidGenUiEvent(error.to_string()))?;
362        validate_genui_payload(session_id, event.kind, &payload)?;
363        if let Some(previous) = states.get(&payload.action_id)
364            && !previous.1.authority_eq(&payload)
365        {
366            return Err(EventError::GenUiIllegalTransition {
367                action: payload.action_id.clone(),
368                from: previous.0.as_str().to_owned(),
369                to: event.kind.as_str().to_owned(),
370            });
371        }
372        let previous = states.get(&payload.action_id);
373        if !legal_genui_transition(previous, event.kind, &payload) {
374            return Err(EventError::GenUiIllegalTransition {
375                action: payload.action_id.clone(),
376                from: previous
377                    .map_or_else(|| "none".to_owned(), |state| state.0.as_str().to_owned()),
378                to: event.kind.as_str().to_owned(),
379            });
380        }
381        states.insert(payload.action_id.clone(), (event.kind, payload));
382    }
383    Ok(states)
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(rename_all = "snake_case")]
388pub enum EventKind {
389    SessionCreated,
390    UserGoal,
391    ModelRequest,
392    ModelResponse,
393    ToolRequest,
394    ToolResult,
395    FileMutation,
396    CommandExecution,
397    GitState,
398    CandidateReady,
399    FalsegreenResult,
400    RepairStarted,
401    SessionReplaced,
402    Checkpoint,
403    TerminalState,
404    GenUiActionPresented,
405    GenUiActionConfirmationRequested,
406    GenUiActionConfirmed,
407    GenUiActionExecutionStarted,
408    GenUiActionExecutionCompleted,
409    GenUiActionExecutionFailed,
410    GenUiActionExecutionUnknown,
411    CompositionRequested,
412    ModelProposalReceived,
413    CompositionRejected,
414    CompositionAccepted,
415    TrustedHandleResolved,
416    CompositionFallbackUsed,
417}
418
419impl EventKind {
420    #[must_use]
421    pub const fn as_str(self) -> &'static str {
422        match self {
423            Self::SessionCreated => "session_created",
424            Self::UserGoal => "user_goal",
425            Self::ModelRequest => "model_request",
426            Self::ModelResponse => "model_response",
427            Self::ToolRequest => "tool_request",
428            Self::ToolResult => "tool_result",
429            Self::FileMutation => "file_mutation",
430            Self::CommandExecution => "command_execution",
431            Self::GitState => "git_state",
432            Self::CandidateReady => "candidate_ready",
433            Self::FalsegreenResult => "falsegreen_result",
434            Self::RepairStarted => "repair_started",
435            Self::SessionReplaced => "session_replaced",
436            Self::Checkpoint => "checkpoint",
437            Self::TerminalState => "terminal_state",
438            Self::GenUiActionPresented => "genui_action_presented",
439            Self::GenUiActionConfirmationRequested => "genui_action_confirmation_requested",
440            Self::GenUiActionConfirmed => "genui_action_confirmed",
441            Self::GenUiActionExecutionStarted => "genui_action_execution_started",
442            Self::GenUiActionExecutionCompleted => "genui_action_execution_completed",
443            Self::GenUiActionExecutionFailed => "genui_action_execution_failed",
444            Self::GenUiActionExecutionUnknown => "genui_action_execution_unknown",
445            Self::CompositionRequested => "composition_requested",
446            Self::ModelProposalReceived => "model_proposal_received",
447            Self::CompositionRejected => "composition_rejected",
448            Self::CompositionAccepted => "composition_accepted",
449            Self::TrustedHandleResolved => "trusted_handle_resolved",
450            Self::CompositionFallbackUsed => "composition_fallback_used",
451        }
452    }
453
454    fn parse(value: &str) -> Result<Self, EventError> {
455        match value {
456            "session_created" => Ok(Self::SessionCreated),
457            "user_goal" => Ok(Self::UserGoal),
458            "model_request" => Ok(Self::ModelRequest),
459            "model_response" => Ok(Self::ModelResponse),
460            "tool_request" => Ok(Self::ToolRequest),
461            "tool_result" => Ok(Self::ToolResult),
462            "file_mutation" => Ok(Self::FileMutation),
463            "command_execution" => Ok(Self::CommandExecution),
464            "git_state" => Ok(Self::GitState),
465            "candidate_ready" => Ok(Self::CandidateReady),
466            "falsegreen_result" => Ok(Self::FalsegreenResult),
467            "repair_started" => Ok(Self::RepairStarted),
468            "session_replaced" => Ok(Self::SessionReplaced),
469            "checkpoint" => Ok(Self::Checkpoint),
470            "terminal_state" => Ok(Self::TerminalState),
471            "genui_action_presented" => Ok(Self::GenUiActionPresented),
472            "genui_action_confirmation_requested" => Ok(Self::GenUiActionConfirmationRequested),
473            "genui_action_confirmed" => Ok(Self::GenUiActionConfirmed),
474            "genui_action_execution_started" => Ok(Self::GenUiActionExecutionStarted),
475            "genui_action_execution_completed" => Ok(Self::GenUiActionExecutionCompleted),
476            "genui_action_execution_failed" => Ok(Self::GenUiActionExecutionFailed),
477            "genui_action_execution_unknown" => Ok(Self::GenUiActionExecutionUnknown),
478            "composition_requested" => Ok(Self::CompositionRequested),
479            "model_proposal_received" => Ok(Self::ModelProposalReceived),
480            "composition_rejected" => Ok(Self::CompositionRejected),
481            "composition_accepted" => Ok(Self::CompositionAccepted),
482            "trusted_handle_resolved" => Ok(Self::TrustedHandleResolved),
483            "composition_fallback_used" => Ok(Self::CompositionFallbackUsed),
484            other => Err(EventError::InvalidKind(other.to_owned())),
485        }
486    }
487
488    #[must_use]
489    pub(crate) const fn is_genui(self) -> bool {
490        matches!(
491            self,
492            Self::GenUiActionPresented
493                | Self::GenUiActionConfirmationRequested
494                | Self::GenUiActionConfirmed
495                | Self::GenUiActionExecutionStarted
496                | Self::GenUiActionExecutionCompleted
497                | Self::GenUiActionExecutionFailed
498                | Self::GenUiActionExecutionUnknown
499        )
500    }
501
502    #[must_use]
503    pub(crate) const fn is_composition(self) -> bool {
504        matches!(
505            self,
506            Self::CompositionRequested
507                | Self::ModelProposalReceived
508                | Self::CompositionRejected
509                | Self::CompositionAccepted
510                | Self::TrustedHandleResolved
511                | Self::CompositionFallbackUsed
512        )
513    }
514}
515
516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
517pub struct Event {
518    pub sequence: u64,
519    pub session_id: String,
520    pub kind: EventKind,
521    pub created_at_ms: u64,
522    pub payload: Value,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub struct SessionReplacementRecord {
527    pub predecessor_session_id: String,
528    pub replacement_session_id: String,
529    pub predecessor_state: String,
530    pub predecessor_history_sha256: String,
531    pub candidate_sha256: String,
532    pub candidate_event_sequence: u64,
533    pub source_git_state_sequence: u64,
534    pub falsegreen_task_id: String,
535    pub created_at_ms: u64,
536}
537
538#[derive(Debug)]
539pub struct EventStore {
540    connection: Connection,
541    path: Option<PathBuf>,
542}
543
544impl EventStore {
545    pub fn open(path: impl AsRef<Path>) -> Result<Self, EventError> {
546        let path = path.as_ref();
547        let connection = Connection::open(path)?;
548        connection.busy_timeout(Duration::from_secs(5))?;
549        connection.pragma_update(None, "journal_mode", "WAL")?;
550        connection.execute_batch(
551            "CREATE TABLE IF NOT EXISTS events (
552                sequence INTEGER PRIMARY KEY AUTOINCREMENT,
553                session_id TEXT NOT NULL,
554                kind TEXT NOT NULL,
555                created_at_ms INTEGER NOT NULL,
556                payload_json TEXT NOT NULL
557            );
558            CREATE INDEX IF NOT EXISTS events_session_sequence
559                ON events(session_id, sequence);
560            CREATE TABLE IF NOT EXISTS session_replacements (
561                predecessor_session_id TEXT PRIMARY KEY,
562                replacement_session_id TEXT NOT NULL UNIQUE,
563                predecessor_state TEXT NOT NULL,
564                predecessor_history_sha256 TEXT NOT NULL,
565                candidate_sha256 TEXT NOT NULL,
566                candidate_event_sequence INTEGER NOT NULL,
567                source_git_state_sequence INTEGER NOT NULL,
568                falsegreen_task_id TEXT NOT NULL,
569                created_at_ms INTEGER NOT NULL
570            );
571            CREATE TABLE IF NOT EXISTS genui_current_states (
572                session_id TEXT NOT NULL,
573                principal TEXT NOT NULL,
574                generation INTEGER NOT NULL,
575                digest TEXT NOT NULL,
576                authorization_context TEXT NOT NULL,
577                PRIMARY KEY (session_id, principal)
578            );
579            CREATE TABLE IF NOT EXISTS authority_generation (
580                id INTEGER PRIMARY KEY CHECK (id = 1),
581                generation INTEGER NOT NULL
582            );
583            INSERT INTO authority_generation (id, generation)
584                VALUES (1, 0)
585                ON CONFLICT(id) DO NOTHING;",
586        )?;
587        let path = if path == Path::new(":memory:") {
588            None
589        } else {
590            Some(fs::canonicalize(path)?)
591        };
592        Ok(Self { connection, path })
593    }
594
595    pub fn open_memory() -> Result<Self, EventError> {
596        Self::open(":memory:")
597    }
598
599    #[must_use]
600    pub fn path(&self) -> Option<&Path> {
601        self.path.as_deref()
602    }
603
604    /// Monotonic durable authority generation. It is incremented in the same
605    /// SQLite transaction as every event, lifecycle, trusted-state, or
606    /// replacement mutation, so a publication fence can use it as a real
607    /// mutation-time token rather than inferring change from a later read.
608    pub fn authority_generation(&self) -> Result<u64, EventError> {
609        let generation = self.connection.query_row(
610            "SELECT generation FROM authority_generation WHERE id = 1",
611            [],
612            |row| row.get::<_, i64>(0),
613        )?;
614        u64::try_from(generation).map_err(|_| {
615            EventError::InvalidGenUiEvent("authority generation exceeds SQLite range".to_owned())
616        })
617    }
618
619    pub fn append(
620        &mut self,
621        session_id: &str,
622        kind: EventKind,
623        payload: &Value,
624    ) -> Result<Event, EventError> {
625        let _authority_guard = crate::mcp::acquire_publication_guard();
626        if kind.is_genui() {
627            return Err(EventError::GenUiEventMustUseLifecycle);
628        }
629        let created_at_ms = now_ms()?;
630        let transaction = self
631            .connection
632            .transaction_with_behavior(TransactionBehavior::Immediate)?;
633        if session_is_terminal(&transaction, session_id)? && !kind.is_composition() {
634            return Err(EventError::TerminalSessionImmutable(session_id.to_owned()));
635        }
636        let event = insert_event(&transaction, session_id, kind, created_at_ms, payload)?;
637        bump_authority_generation(&transaction)?;
638        transaction.commit()?;
639        Ok(event)
640    }
641
642    /// Append one typed GenUI lifecycle event. GenUI payloads are parsed with
643    /// a closed schema and the complete action history is reconstructed inside
644    /// the same SQLite transaction before the new event is committed.
645    pub(crate) fn append_genui_lifecycle(
646        &mut self,
647        session_id: &str,
648        kind: EventKind,
649        payload: &Value,
650    ) -> Result<Event, EventError> {
651        let _authority_guard = crate::mcp::acquire_publication_guard();
652        let typed: GenUiActionEventPayload = serde_json::from_value(payload.clone())?;
653        validate_genui_payload(session_id, kind, &typed)?;
654        let transaction = self
655            .connection
656            .transaction_with_behavior(TransactionBehavior::Immediate)?;
657        if session_is_terminal(&transaction, session_id)? {
658            return Err(EventError::TerminalSessionImmutable(session_id.to_owned()));
659        }
660        let events = load_events(&transaction, session_id)?;
661        let states = replay_genui_history(&events, session_id)?;
662        let previous = states.get(&typed.action_id);
663        if let Some((previous_kind, previous_payload)) = previous
664            && !previous_payload.authority_eq(&typed)
665        {
666            return Err(EventError::GenUiIllegalTransition {
667                action: typed.action_id.clone(),
668                from: previous_kind.as_str().to_owned(),
669                to: kind.as_str().to_owned(),
670            });
671        }
672        if !legal_genui_transition(previous, kind, &typed) {
673            return Err(EventError::GenUiIllegalTransition {
674                action: typed.action_id.clone(),
675                from: previous
676                    .map_or_else(|| "none".to_owned(), |(kind, _)| kind.as_str().to_owned()),
677                to: kind.as_str().to_owned(),
678            });
679        }
680        let created_at_ms = now_ms()?;
681        let event = insert_event(&transaction, session_id, kind, created_at_ms, payload)?;
682        bump_authority_generation(&transaction)?;
683        transaction.commit()?;
684        Ok(event)
685    }
686
687    /// Atomically transition a confirmed GenUI action into execution. The
688    /// transition is kept in the append-only session history so concurrent
689    /// processes and restarts observe the same lifecycle.
690    pub fn start_genui_action(
691        &mut self,
692        session_id: &str,
693        action_id: &str,
694        identity: &Value,
695        requires_confirmation: bool,
696    ) -> Result<Event, EventError> {
697        let typed: GenUiActionEventPayload = serde_json::from_value(identity.clone())
698            .map_err(|_| EventError::GenUiActionNotConfirmed(action_id.to_owned()))?;
699        if typed.action_id != action_id
700            || typed.session_id != session_id
701            || typed.requires_confirmation != requires_confirmation
702        {
703            return Err(EventError::GenUiActionNotConfirmed(action_id.to_owned()));
704        }
705        let result = self.append_genui_lifecycle(
706            session_id,
707            EventKind::GenUiActionExecutionStarted,
708            identity,
709        );
710        result.map_err(|error| match error {
711            EventError::GenUiIllegalTransition { from, .. }
712                if from == EventKind::GenUiActionExecutionUnknown.as_str() =>
713            {
714                EventError::GenUiActionOutcomeUnknown(action_id.to_owned())
715            }
716            EventError::GenUiIllegalTransition { .. } if requires_confirmation => {
717                EventError::GenUiActionAlreadyStarted(action_id.to_owned())
718            }
719            EventError::GenUiActionOutcomeUnknown(_) => error,
720            EventError::GenUiIllegalTransition { .. } => {
721                EventError::GenUiActionNotConfirmed(action_id.to_owned())
722            }
723            other => other,
724        })
725    }
726
727    pub(crate) fn complete_genui_action(
728        &mut self,
729        session_id: &str,
730        action_id: &str,
731        identity: &Value,
732        ok: bool,
733        result_digest: &str,
734    ) -> Result<Event, EventError> {
735        let mut payload: GenUiActionEventPayload = serde_json::from_value(identity.clone())?;
736        if payload.action_id != action_id || payload.session_id != session_id {
737            return Err(EventError::InvalidGenUiEvent(
738                "completion identity mismatch".to_owned(),
739            ));
740        }
741        payload.ok = Some(ok);
742        payload.result_digest = Some(result_digest.to_owned());
743        let kind = if ok {
744            EventKind::GenUiActionExecutionCompleted
745        } else {
746            EventKind::GenUiActionExecutionFailed
747        };
748        self.append_genui_lifecycle(session_id, kind, &serde_json::to_value(payload)?)
749    }
750
751    /// Terminally reconcile a durable execution start when the host authority
752    /// changed before transport. This records an explicit unknown outcome so a
753    /// later continuation cannot mistake the stale action for an unattempted
754    /// execution or replay it against a new workspace.
755    pub(crate) fn mark_genui_action_unknown(
756        &mut self,
757        session_id: &str,
758        action_id: &str,
759        identity: &Value,
760        reason: &str,
761    ) -> Result<Event, EventError> {
762        let mut payload: GenUiActionEventPayload = serde_json::from_value(identity.clone())?;
763        if payload.action_id != action_id || payload.session_id != session_id {
764            return Err(EventError::InvalidGenUiEvent(
765                "unknown outcome identity mismatch".to_owned(),
766            ));
767        }
768        if reason.is_empty() {
769            return Err(EventError::InvalidGenUiEvent(
770                "unknown outcome reason must not be empty".to_owned(),
771            ));
772        }
773        payload.reason = Some(reason.to_owned());
774        self.append_genui_lifecycle(
775            session_id,
776            EventKind::GenUiActionExecutionUnknown,
777            &serde_json::to_value(payload)?,
778        )
779    }
780
781    pub fn events(&self, session_id: &str) -> Result<Vec<Event>, EventError> {
782        load_events(&self.connection, session_id)
783    }
784
785    /// Reconstruct one complete GenUI lifecycle from durable history. Resume
786    /// paths must use this method rather than searching for an individual
787    /// event kind, so malformed, reordered, truncated, or terminal histories
788    /// fail closed in exactly the same validator used for normal appends.
789    pub(crate) fn replay_genui_action(
790        &self,
791        session_id: &str,
792        action_id: &str,
793    ) -> Result<Option<(EventKind, Value)>, EventError> {
794        let events = load_events(&self.connection, session_id)?;
795        if events
796            .iter()
797            .any(|event| event.kind == EventKind::TerminalState)
798        {
799            return Err(EventError::TerminalSessionImmutable(session_id.to_owned()));
800        }
801        let states = replay_genui_history(&events, session_id)?;
802        Ok(states.get(action_id).map(|(kind, payload)| {
803            (
804                *kind,
805                serde_json::to_value(payload).expect("typed payload serializes"),
806            )
807        }))
808    }
809
810    pub(crate) fn genui_current_state(
811        &self,
812        session_id: &str,
813        principal: &str,
814    ) -> Result<Option<(u64, String, String)>, EventError> {
815        let state = self
816            .connection
817            .query_row(
818                "SELECT generation, digest, authorization_context
819                 FROM genui_current_states WHERE session_id = ?1 AND principal = ?2",
820                params![session_id, principal],
821                |row| {
822                    let generation = row.get::<_, i64>(0)?;
823                    let generation = u64::try_from(generation)
824                        .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, generation))?;
825                    Ok((
826                        generation,
827                        row.get::<_, String>(1)?,
828                        row.get::<_, String>(2)?,
829                    ))
830                },
831            )
832            .optional()
833            .map_err(EventError::from)?;
834        if let Some((generation, digest, authorization_context)) = &state
835            && (*generation == 0 || digest.is_empty() || authorization_context.is_empty())
836        {
837            return Err(EventError::InvalidGenUiEvent(
838                "durable current state is malformed".to_owned(),
839            ));
840        }
841        Ok(state)
842    }
843
844    #[cfg(test)]
845    pub(crate) fn set_genui_current_state(
846        &mut self,
847        session_id: &str,
848        principal: &str,
849        generation: u64,
850        digest: &str,
851        authorization_context: &str,
852    ) -> Result<(), EventError> {
853        let _authority_guard = crate::mcp::acquire_publication_guard();
854        let generation_sql = i64::try_from(generation).map_err(|_| {
855            EventError::InvalidGenUiEvent(
856                "trusted state generation exceeds SQLite range".to_owned(),
857            )
858        })?;
859        let transaction = self
860            .connection
861            .transaction_with_behavior(TransactionBehavior::Immediate)?;
862        let existing = transaction
863            .query_row(
864                "SELECT generation, digest, authorization_context
865                 FROM genui_current_states WHERE session_id = ?1 AND principal = ?2",
866                params![session_id, principal],
867                |row| {
868                    let generation = row.get::<_, i64>(0)?;
869                    let generation = u64::try_from(generation)
870                        .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, generation))?;
871                    Ok((
872                        generation,
873                        row.get::<_, String>(1)?,
874                        row.get::<_, String>(2)?,
875                    ))
876                },
877            )
878            .optional()?;
879        if let Some((previous_generation, previous_digest, previous_context)) = existing {
880            if generation < previous_generation {
881                return Err(EventError::GenUiStateGenerationRewind);
882            }
883            if generation == previous_generation
884                && (digest != previous_digest || authorization_context != previous_context)
885            {
886                return Err(EventError::GenUiStateIdentityChanged);
887            }
888        }
889        transaction.execute(
890            "INSERT INTO genui_current_states
891             (session_id, principal, generation, digest, authorization_context)
892             VALUES (?1, ?2, ?3, ?4, ?5)
893             ON CONFLICT(session_id, principal) DO UPDATE SET
894               generation = excluded.generation,
895               digest = excluded.digest,
896               authorization_context = excluded.authorization_context",
897            params![
898                session_id,
899                principal,
900                generation_sql,
901                digest,
902                authorization_context
903            ],
904        )?;
905        bump_authority_generation(&transaction)?;
906        transaction.commit()?;
907        Ok(())
908    }
909
910    pub(crate) fn advance_genui_current_state(
911        &mut self,
912        session_id: &str,
913        principal: &str,
914        digest: &str,
915        authorization_context: &str,
916    ) -> Result<u64, EventError> {
917        let _authority_guard = crate::mcp::acquire_publication_guard();
918        if session_id.is_empty()
919            || principal.is_empty()
920            || digest.is_empty()
921            || authorization_context.is_empty()
922        {
923            return Err(EventError::InvalidGenUiEvent(
924                "durable current state identity must not be empty".to_owned(),
925            ));
926        }
927        let transaction = self
928            .connection
929            .transaction_with_behavior(TransactionBehavior::Immediate)?;
930        let previous = transaction
931            .query_row(
932                "SELECT generation FROM genui_current_states
933                 WHERE session_id = ?1 AND principal = ?2",
934                params![session_id, principal],
935                |row| row.get::<_, i64>(0),
936            )
937            .optional()?;
938        let generation = previous.map_or(1, |value| {
939            u64::try_from(value).unwrap_or(u64::MAX).saturating_add(1)
940        });
941        let generation_sql = i64::try_from(generation).map_err(|_| {
942            EventError::InvalidGenUiEvent(
943                "trusted state generation exceeds SQLite range".to_owned(),
944            )
945        })?;
946        transaction.execute(
947            "INSERT INTO genui_current_states
948             (session_id, principal, generation, digest, authorization_context)
949             VALUES (?1, ?2, ?3, ?4, ?5)
950             ON CONFLICT(session_id, principal) DO UPDATE SET
951               generation = excluded.generation,
952               digest = excluded.digest,
953               authorization_context = excluded.authorization_context",
954            params![
955                session_id,
956                principal,
957                generation_sql,
958                digest,
959                authorization_context
960            ],
961        )?;
962        bump_authority_generation(&transaction)?;
963        transaction.commit()?;
964        Ok(generation)
965    }
966
967    /// Qualification-only durable-state mutation seam. Normal callers must
968    /// advance GenUI state through the Agent/workspace authority boundary.
969    #[doc(hidden)]
970    pub fn advance_genui_current_state_for_testing(
971        &mut self,
972        session_id: &str,
973        principal: &str,
974        digest: &str,
975        authorization_context: &str,
976    ) -> Result<u64, EventError> {
977        self.advance_genui_current_state(session_id, principal, digest, authorization_context)
978    }
979
980    /// Convert any in-flight GenUI execution to an explicit unknown outcome
981    /// after a process restart. An external MCP side effect must never be
982    /// retried blindly when its prior outcome is not durable.
983    pub fn reconcile_genui_actions(&mut self, session_id: &str) -> Result<usize, EventError> {
984        let events = self.events(session_id)?;
985        let states = replay_genui_history(&events, session_id)?;
986        let unresolved = states
987            .iter()
988            .filter_map(|(action_id, (kind, payload))| {
989                (*kind == EventKind::GenUiActionExecutionStarted)
990                    .then_some((action_id.clone(), payload.clone()))
991            })
992            .collect::<Vec<_>>();
993        for (_action_id, mut payload) in unresolved {
994            payload.reason = Some("process_restart_before_external_outcome_was_durable".to_owned());
995            self.append_genui_lifecycle(
996                session_id,
997                EventKind::GenUiActionExecutionUnknown,
998                &serde_json::to_value(payload)?,
999            )?;
1000        }
1001        Ok(states
1002            .values()
1003            .filter(|(kind, _)| *kind == EventKind::GenUiActionExecutionStarted)
1004            .count())
1005    }
1006
1007    pub(crate) fn create_session_replacement(
1008        &mut self,
1009        mut record: SessionReplacementRecord,
1010        event_payloads: &[(EventKind, Value)],
1011    ) -> Result<SessionReplacementRecord, EventError> {
1012        let _authority_guard = crate::mcp::acquire_publication_guard();
1013        record.created_at_ms = now_ms()?;
1014        validate_replacement_event_payloads(&record, event_payloads)?;
1015        let transaction = self
1016            .connection
1017            .transaction_with_behavior(TransactionBehavior::Immediate)?;
1018        let predecessor_events = load_events(&transaction, &record.predecessor_session_id)?;
1019        if history_sha256(&predecessor_events)? != record.predecessor_history_sha256 {
1020            return Err(EventError::PredecessorHistoryChanged);
1021        }
1022        if replacement_row_by_replacement(&transaction, &record.predecessor_session_id)?.is_some() {
1023            return Err(EventError::ReplacementChainUnsupported);
1024        }
1025        if let Some(existing) =
1026            replacement_row_by_predecessor(&transaction, &record.predecessor_session_id)?
1027        {
1028            return Err(EventError::ReplacementAlreadyExists {
1029                predecessor: existing.predecessor_session_id,
1030                replacement: existing.replacement_session_id,
1031            });
1032        }
1033        if !load_events(&transaction, &record.replacement_session_id)?.is_empty()
1034            || replacement_row_by_replacement(&transaction, &record.replacement_session_id)?
1035                .is_some()
1036        {
1037            return Err(EventError::MalformedReplacement(
1038                "generated replacement session ID is already in use".to_owned(),
1039            ));
1040        }
1041        transaction.execute(
1042            "INSERT INTO session_replacements (
1043                predecessor_session_id, replacement_session_id, predecessor_state,
1044                predecessor_history_sha256, candidate_sha256, candidate_event_sequence,
1045                source_git_state_sequence, falsegreen_task_id, created_at_ms
1046             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1047            params![
1048                record.predecessor_session_id,
1049                record.replacement_session_id,
1050                record.predecessor_state,
1051                record.predecessor_history_sha256,
1052                record.candidate_sha256,
1053                record.candidate_event_sequence,
1054                record.source_git_state_sequence,
1055                record.falsegreen_task_id,
1056                record.created_at_ms,
1057            ],
1058        )?;
1059        for (kind, payload) in event_payloads {
1060            insert_event(
1061                &transaction,
1062                &record.replacement_session_id,
1063                *kind,
1064                record.created_at_ms,
1065                payload,
1066            )?;
1067        }
1068        bump_authority_generation(&transaction)?;
1069        transaction.commit()?;
1070        Ok(record)
1071    }
1072
1073    pub fn replacement_for_session(
1074        &self,
1075        replacement_session_id: &str,
1076    ) -> Result<Option<SessionReplacementRecord>, EventError> {
1077        replacement_row_by_replacement(&self.connection, replacement_session_id)?
1078            .map(|record| validate_replacement_record(&self.connection, record))
1079            .transpose()
1080    }
1081
1082    pub fn replacement_for_predecessor(
1083        &self,
1084        predecessor_session_id: &str,
1085    ) -> Result<Option<SessionReplacementRecord>, EventError> {
1086        replacement_row_by_predecessor(&self.connection, predecessor_session_id)?
1087            .map(|record| validate_replacement_record(&self.connection, record))
1088            .transpose()
1089    }
1090
1091    /// Return known session IDs from most recently active to least recently active.
1092    ///
1093    /// This is intentionally a small event-store query rather than a mutable
1094    /// "current session" pointer. Resume selection can therefore be rebuilt from
1095    /// the same append-only history used by the authority boundary.
1096    pub fn session_ids_by_recency(&self) -> Result<Vec<String>, EventError> {
1097        let mut statement = self.connection.prepare(
1098            "SELECT session_id, MAX(sequence) AS latest_sequence
1099             FROM events
1100             GROUP BY session_id
1101             ORDER BY latest_sequence DESC, session_id ASC",
1102        )?;
1103        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
1104        let mut session_ids = Vec::new();
1105        for row in rows {
1106            session_ids.push(row?);
1107        }
1108        Ok(session_ids)
1109    }
1110}
1111
1112fn insert_event(
1113    transaction: &Transaction<'_>,
1114    session_id: &str,
1115    kind: EventKind,
1116    created_at_ms: u64,
1117    payload: &Value,
1118) -> Result<Event, EventError> {
1119    transaction.execute(
1120        "INSERT INTO events(session_id, kind, created_at_ms, payload_json)
1121         VALUES (?1, ?2, ?3, ?4)",
1122        params![
1123            session_id,
1124            kind.as_str(),
1125            created_at_ms,
1126            serde_json::to_string(payload)?
1127        ],
1128    )?;
1129    let sequence = u64::try_from(transaction.last_insert_rowid())
1130        .map_err(|_| EventError::InvalidKind("negative SQLite sequence".to_owned()))?;
1131    Ok(Event {
1132        sequence,
1133        session_id: session_id.to_owned(),
1134        kind,
1135        created_at_ms,
1136        payload: payload.clone(),
1137    })
1138}
1139
1140fn session_is_terminal(connection: &Connection, session_id: &str) -> Result<bool, EventError> {
1141    Ok(connection.query_row(
1142        "SELECT EXISTS(
1143            SELECT 1 FROM events WHERE session_id = ?1 AND kind = 'terminal_state'
1144        )",
1145        [session_id],
1146        |row| row.get(0),
1147    )?)
1148}
1149
1150fn load_events(connection: &Connection, session_id: &str) -> Result<Vec<Event>, EventError> {
1151    let mut statement = connection.prepare(
1152        "SELECT sequence, session_id, kind, created_at_ms, payload_json
1153         FROM events WHERE session_id = ?1 ORDER BY sequence ASC",
1154    )?;
1155    let rows = statement.query_map([session_id], |row| {
1156        Ok((
1157            row.get::<_, u64>(0)?,
1158            row.get::<_, String>(1)?,
1159            row.get::<_, String>(2)?,
1160            row.get::<_, u64>(3)?,
1161            row.get::<_, String>(4)?,
1162        ))
1163    })?;
1164    let mut events = Vec::new();
1165    for row in rows {
1166        let (sequence, stored_session_id, kind, created_at_ms, payload_json) = row?;
1167        events.push(Event {
1168            sequence,
1169            session_id: stored_session_id,
1170            kind: EventKind::parse(&kind)?,
1171            created_at_ms,
1172            payload: serde_json::from_str(&payload_json)?,
1173        });
1174    }
1175    Ok(events)
1176}
1177
1178pub fn history_sha256(events: &[Event]) -> Result<String, EventError> {
1179    let mut digest = Sha256::new();
1180    digest.update(serde_json::to_vec(events)?);
1181    Ok(format!("{:x}", digest.finalize()))
1182}
1183
1184fn replacement_row_by_predecessor(
1185    connection: &Connection,
1186    predecessor_session_id: &str,
1187) -> Result<Option<SessionReplacementRecord>, EventError> {
1188    replacement_row(
1189        connection,
1190        "SELECT predecessor_session_id, replacement_session_id, predecessor_state,
1191                predecessor_history_sha256, candidate_sha256, candidate_event_sequence,
1192                source_git_state_sequence, falsegreen_task_id, created_at_ms
1193           FROM session_replacements WHERE predecessor_session_id = ?1",
1194        predecessor_session_id,
1195    )
1196}
1197
1198fn replacement_row_by_replacement(
1199    connection: &Connection,
1200    replacement_session_id: &str,
1201) -> Result<Option<SessionReplacementRecord>, EventError> {
1202    replacement_row(
1203        connection,
1204        "SELECT predecessor_session_id, replacement_session_id, predecessor_state,
1205                predecessor_history_sha256, candidate_sha256, candidate_event_sequence,
1206                source_git_state_sequence, falsegreen_task_id, created_at_ms
1207           FROM session_replacements WHERE replacement_session_id = ?1",
1208        replacement_session_id,
1209    )
1210}
1211
1212fn replacement_row(
1213    connection: &Connection,
1214    query: &str,
1215    id: &str,
1216) -> Result<Option<SessionReplacementRecord>, EventError> {
1217    Ok(connection
1218        .query_row(query, [id], |row| {
1219            Ok(SessionReplacementRecord {
1220                predecessor_session_id: row.get(0)?,
1221                replacement_session_id: row.get(1)?,
1222                predecessor_state: row.get(2)?,
1223                predecessor_history_sha256: row.get(3)?,
1224                candidate_sha256: row.get(4)?,
1225                candidate_event_sequence: row.get(5)?,
1226                source_git_state_sequence: row.get(6)?,
1227                falsegreen_task_id: row.get(7)?,
1228                created_at_ms: row.get(8)?,
1229            })
1230        })
1231        .optional()?)
1232}
1233
1234fn validate_replacement_event_payloads(
1235    record: &SessionReplacementRecord,
1236    events: &[(EventKind, Value)],
1237) -> Result<(), EventError> {
1238    if !replacement_record_shape_is_valid(record) {
1239        return Err(EventError::MalformedReplacement(
1240            "replacement record contains invalid identity fields".to_owned(),
1241        ));
1242    }
1243    let matching: Vec<&Value> = events
1244        .iter()
1245        .filter_map(|(kind, payload)| (*kind == EventKind::SessionReplaced).then_some(payload))
1246        .collect();
1247    if matching.len() != 1 || !replacement_payload_matches(record, matching[0]) {
1248        return Err(EventError::MalformedReplacement(
1249            "atomic replacement batch must contain one exact session_replaced event".to_owned(),
1250        ));
1251    }
1252    let authority_bindings: Vec<&Value> = events
1253        .iter()
1254        .filter_map(|(kind, payload)| {
1255            (*kind == EventKind::Checkpoint && payload["checkpoint_kind"] == "acceptance_authority")
1256                .then_some(payload)
1257        })
1258        .collect();
1259    if authority_bindings.len() != 1
1260        || authority_bindings[0]["falsegreen_task_id"].as_str() != Some(&record.falsegreen_task_id)
1261        || authority_bindings[0]["replacement_predecessor_session_id"].as_str()
1262            != Some(&record.predecessor_session_id)
1263    {
1264        return Err(EventError::MalformedReplacement(
1265            "atomic replacement batch must contain one exact authority binding".to_owned(),
1266        ));
1267    }
1268    Ok(())
1269}
1270
1271fn validate_replacement_record(
1272    connection: &Connection,
1273    record: SessionReplacementRecord,
1274) -> Result<SessionReplacementRecord, EventError> {
1275    if !replacement_record_shape_is_valid(&record) {
1276        return Err(EventError::MalformedReplacement(
1277            "replacement record contains invalid identity fields".to_owned(),
1278        ));
1279    }
1280    if replacement_row_by_replacement(connection, &record.predecessor_session_id)?.is_some() {
1281        return Err(EventError::MalformedReplacement(
1282            "replacement chains are forbidden".to_owned(),
1283        ));
1284    }
1285    let predecessor_events = load_events(connection, &record.predecessor_session_id)?;
1286    if predecessor_events.is_empty()
1287        || history_sha256(&predecessor_events)? != record.predecessor_history_sha256
1288    {
1289        return Err(EventError::MalformedReplacement(
1290            "predecessor history no longer matches its replacement record".to_owned(),
1291        ));
1292    }
1293    let predecessor_terminal = predecessor_events.last().ok_or_else(|| {
1294        EventError::MalformedReplacement("predecessor history is empty".to_owned())
1295    })?;
1296    if predecessor_terminal.kind != EventKind::TerminalState
1297        || predecessor_terminal.payload["state"].as_str() != Some(&record.predecessor_state)
1298    {
1299        return Err(EventError::MalformedReplacement(
1300            "predecessor terminal state disagrees with replacement record".to_owned(),
1301        ));
1302    }
1303    let candidate_event = predecessor_events
1304        .iter()
1305        .find(|event| event.sequence == record.candidate_event_sequence)
1306        .filter(|event| {
1307            event.kind == EventKind::CandidateReady
1308                && event.payload["candidate_sha256"].as_str() == Some(&record.candidate_sha256)
1309        })
1310        .ok_or_else(|| {
1311            EventError::MalformedReplacement(
1312                "predecessor candidate event disagrees with replacement record".to_owned(),
1313            )
1314        })?;
1315    let source_git_state = predecessor_events
1316        .iter()
1317        .find(|event| event.sequence == record.source_git_state_sequence)
1318        .filter(|event| {
1319            event.kind == EventKind::GitState && event.sequence > candidate_event.sequence
1320        })
1321        .ok_or_else(|| {
1322            EventError::MalformedReplacement(
1323                "predecessor Git-state event disagrees with replacement record".to_owned(),
1324            )
1325        })?;
1326    let task_bindings: Vec<&Event> = predecessor_events
1327        .iter()
1328        .filter(|event| {
1329            event.kind == EventKind::Checkpoint
1330                && event.payload["checkpoint_kind"] == "acceptance_authority"
1331        })
1332        .collect();
1333    if task_bindings.len() != 1
1334        || task_bindings[0].payload["falsegreen_task_id"].as_str()
1335            != Some(&record.falsegreen_task_id)
1336    {
1337        return Err(EventError::MalformedReplacement(
1338            "predecessor task authority disagrees with replacement record".to_owned(),
1339        ));
1340    }
1341    let replacement_events = load_events(connection, &record.replacement_session_id)?;
1342    let matching: Vec<&Value> = replacement_events
1343        .iter()
1344        .filter_map(|event| (event.kind == EventKind::SessionReplaced).then_some(&event.payload))
1345        .collect();
1346    if matching.len() != 1 || !replacement_payload_matches(&record, matching[0]) {
1347        return Err(EventError::MalformedReplacement(
1348            "replacement relation and durable event disagree".to_owned(),
1349        ));
1350    }
1351    let replacement_authority: Vec<&Event> = replacement_events
1352        .iter()
1353        .filter(|event| {
1354            event.kind == EventKind::Checkpoint
1355                && event.payload["checkpoint_kind"] == "acceptance_authority"
1356        })
1357        .collect();
1358    let mut expected_authority = task_bindings[0].payload.clone();
1359    expected_authority["replacement_predecessor_session_id"] =
1360        Value::String(record.predecessor_session_id.clone());
1361    if replacement_authority.len() != 1 || replacement_authority[0].payload != expected_authority {
1362        return Err(EventError::MalformedReplacement(
1363            "replacement task authority disagrees with predecessor and relation".to_owned(),
1364        ));
1365    }
1366    let carried_candidates: Vec<&Event> = replacement_events
1367        .iter()
1368        .filter(|event| {
1369            event.kind == EventKind::CandidateReady
1370                && event.payload["carried_forward"].as_bool() == Some(true)
1371        })
1372        .collect();
1373    if carried_candidates.len() != 1
1374        || carried_candidates[0].payload["candidate_sha256"].as_str()
1375            != Some(&record.candidate_sha256)
1376        || carried_candidates[0].payload["predecessor_session_id"].as_str()
1377            != Some(&record.predecessor_session_id)
1378        || carried_candidates[0].payload["predecessor_candidate_event_sequence"].as_u64()
1379            != Some(record.candidate_event_sequence)
1380    {
1381        return Err(EventError::MalformedReplacement(
1382            "replacement candidate continuity is incomplete".to_owned(),
1383        ));
1384    }
1385    let carried_git_states: Vec<&Event> = replacement_events
1386        .iter()
1387        .filter(|event| event.kind == EventKind::GitState)
1388        .collect();
1389    if carried_git_states.len() != 1
1390        || carried_git_states[0].sequence <= carried_candidates[0].sequence
1391        || carried_git_states[0].payload != source_git_state.payload
1392    {
1393        return Err(EventError::MalformedReplacement(
1394            "replacement source Git-state provenance changed".to_owned(),
1395        ));
1396    }
1397    let validation_checkpoints: Vec<&Event> = replacement_events
1398        .iter()
1399        .filter(|event| {
1400            event.kind == EventKind::Checkpoint
1401                && event.payload["checkpoint_kind"] == "replacement_candidate_validation"
1402        })
1403        .collect();
1404    if validation_checkpoints.len() != 1
1405        || validation_checkpoints[0].payload["state"] != "candidate_ready"
1406        || validation_checkpoints[0].payload["predecessor_session_id"].as_str()
1407            != Some(&record.predecessor_session_id)
1408        || validation_checkpoints[0].payload["expected_candidate_sha256"].as_str()
1409            != Some(&record.candidate_sha256)
1410        || validation_checkpoints[0].payload["actual_candidate_sha256"].as_str()
1411            != Some(&record.candidate_sha256)
1412        || validation_checkpoints[0].payload["matched"].as_bool() != Some(true)
1413    {
1414        return Err(EventError::MalformedReplacement(
1415            "replacement candidate-validation checkpoint disagrees with relation".to_owned(),
1416        ));
1417    }
1418    Ok(record)
1419}
1420
1421fn replacement_record_shape_is_valid(record: &SessionReplacementRecord) -> bool {
1422    record.predecessor_session_id.starts_with("session_")
1423        && record.replacement_session_id.starts_with("session_")
1424        && record.predecessor_session_id != record.replacement_session_id
1425        && record.predecessor_state == "failed"
1426        && valid_sha256(&record.predecessor_history_sha256)
1427        && valid_sha256(&record.candidate_sha256)
1428        && record.candidate_event_sequence > 0
1429        && record.source_git_state_sequence > record.candidate_event_sequence
1430        && !record.falsegreen_task_id.is_empty()
1431}
1432
1433fn valid_sha256(value: &str) -> bool {
1434    value.len() == 64
1435        && value
1436            .bytes()
1437            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1438}
1439
1440fn replacement_payload_matches(record: &SessionReplacementRecord, payload: &Value) -> bool {
1441    payload["predecessor_session_id"].as_str() == Some(&record.predecessor_session_id)
1442        && payload["predecessor_state"].as_str() == Some(&record.predecessor_state)
1443        && payload["predecessor_history_sha256"].as_str()
1444            == Some(&record.predecessor_history_sha256)
1445        && payload["candidate_sha256"].as_str() == Some(&record.candidate_sha256)
1446        && payload["candidate_event_sequence"].as_u64() == Some(record.candidate_event_sequence)
1447        && payload["source_git_state_sequence"].as_u64() == Some(record.source_git_state_sequence)
1448        && payload["falsegreen_task_id"].as_str() == Some(&record.falsegreen_task_id)
1449        && payload["topology"].as_str() == Some("single_direct_replacement_no_chains")
1450}
1451
1452fn now_ms() -> Result<u64, EventError> {
1453    let millis = SystemTime::now()
1454        .duration_since(UNIX_EPOCH)
1455        .map_err(|_| EventError::InvalidClock)?
1456        .as_millis();
1457    u64::try_from(millis).map_err(|_| EventError::InvalidClock)
1458}
1459
1460fn bump_authority_generation(transaction: &Transaction<'_>) -> Result<(), EventError> {
1461    transaction.execute(
1462        "UPDATE authority_generation
1463         SET generation = generation + 1
1464         WHERE id = 1",
1465        [],
1466    )?;
1467    Ok(())
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472    use serde_json::json;
1473    use tempfile::tempdir;
1474
1475    use super::{EventKind, EventStore};
1476
1477    #[test]
1478    fn events_are_strictly_ordered_and_survive_reopen() {
1479        let directory = tempdir().expect("tempdir");
1480        let path = directory.path().join("events.db");
1481        {
1482            let mut store = EventStore::open(&path).expect("open");
1483            store
1484                .append(
1485                    "s1",
1486                    EventKind::SessionCreated,
1487                    &json!({"state": "initializing"}),
1488                )
1489                .expect("append");
1490            store
1491                .append("s1", EventKind::ToolResult, &json!({"ok": true}))
1492                .expect("append");
1493        }
1494        let store = EventStore::open(&path).expect("reopen");
1495        let events = store.events("s1").expect("events");
1496        assert_eq!(events.len(), 2);
1497        assert!(events[0].sequence < events[1].sequence);
1498        assert_eq!(events[1].kind, EventKind::ToolResult);
1499        assert_eq!(events[1].payload, json!({"ok": true}));
1500    }
1501
1502    #[test]
1503    fn lists_sessions_by_latest_activity() {
1504        let mut store = EventStore::open_memory().expect("store");
1505        store
1506            .append("older", EventKind::SessionCreated, &json!({}))
1507            .expect("older");
1508        store
1509            .append("newer", EventKind::SessionCreated, &json!({}))
1510            .expect("newer");
1511        store
1512            .append("older", EventKind::Checkpoint, &json!({}))
1513            .expect("activity");
1514        assert_eq!(
1515            store.session_ids_by_recency().expect("sessions"),
1516            vec!["older", "newer"]
1517        );
1518    }
1519
1520    #[test]
1521    fn trusted_genui_state_is_monotonic_and_survives_restart() {
1522        let directory = tempdir().expect("tempdir");
1523        let path = directory.path().join("state.db");
1524        {
1525            let mut store = EventStore::open(&path).expect("open");
1526            store
1527                .set_genui_current_state("s", "p", 1, "state-1", "auth")
1528                .expect("initial state");
1529            store
1530                .set_genui_current_state("s", "p", 2, "state-2", "auth")
1531                .expect("advance");
1532            assert!(matches!(
1533                store.set_genui_current_state("s", "p", 1, "state-1", "auth"),
1534                Err(super::EventError::GenUiStateGenerationRewind)
1535            ));
1536        }
1537        let store = EventStore::open(&path).expect("reopen");
1538        assert_eq!(
1539            store
1540                .genui_current_state("s", "p")
1541                .expect("state")
1542                .expect("present")
1543                .0,
1544            2
1545        );
1546    }
1547
1548    #[test]
1549    fn durable_genui_replay_rejects_invalid_kind_source_pairs() {
1550        fn payload(action_id: &str, action_kind: &str, source_type: &str) -> serde_json::Value {
1551            json!({
1552                "schema_version": 1,
1553                "action_id": action_id,
1554                "action_kind": action_kind,
1555                "source_type": source_type,
1556                "label_digest": "a".repeat(64),
1557                "surface_id": "surface",
1558                "surface_digest": "b".repeat(64),
1559                "state_digest": "c".repeat(64),
1560                "state_generation": 1,
1561                "session_id": "session",
1562                "principal": "principal",
1563                "authorization_context": "auth",
1564                "provider_id": "provider",
1565                "server_id": "server",
1566                "tool_name": "tool",
1567                "remote_tool_name": "remote",
1568                "schema_digest": "d".repeat(64),
1569                "policy_version": 1,
1570                "policy_digest": "e".repeat(64),
1571                "requires_confirmation": action_kind == "consequential"
1572            })
1573        }
1574
1575        let invalid = [
1576            ("navigation", "mcp"),
1577            ("form_value_update", "mcp"),
1578            ("local_presentation", "mcp"),
1579            ("mcp_tool", "host_local"),
1580            ("auto", "mcp"),
1581            ("mcp_tool", "unknown_source"),
1582            ("unknown_kind", "mcp"),
1583        ];
1584        for (index, (kind, source)) in invalid.into_iter().enumerate() {
1585            let mut store = EventStore::open_memory().expect("store");
1586            let result = store.append_genui_lifecycle(
1587                "session",
1588                EventKind::GenUiActionPresented,
1589                &payload(&format!("action-{index}"), kind, source),
1590            );
1591            assert!(
1592                result.is_err(),
1593                "invalid pair unexpectedly persisted: {kind}/{source}"
1594            );
1595        }
1596
1597        for (index, (kind, source)) in [("mcp_tool", "mcp"), ("navigation", "host_local")]
1598            .into_iter()
1599            .enumerate()
1600        {
1601            let mut store = EventStore::open_memory().expect("store");
1602            store
1603                .append_genui_lifecycle(
1604                    "session",
1605                    EventKind::GenUiActionPresented,
1606                    &payload(&format!("valid-{index}"), kind, source),
1607                )
1608                .expect("valid pair persists");
1609            assert_eq!(store.events("session").expect("events").len(), 1);
1610        }
1611    }
1612}