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#[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 #[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 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 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 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 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 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 #[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 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 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}