1use std::collections::{BTreeMap, BTreeSet};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use kmp_domain::{ContextEventStore, ContextUpdatedEvent, PortError, ProjectionMutation};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use super::replay::ProjectionRebuildReport;
15use super::store::EmbeddedKernelStore;
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct BundleEventRange {
24 pub first: Option<u64>,
25 pub last: Option<u64>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct BundleHeader {
33 pub bundle_format: u32,
34 #[serde(rename = "event_format", alias = "store_format")]
38 pub event_format: u32,
39 pub event_count: u64,
40 pub kernel_version: String,
41 #[serde(default)]
42 pub snapshot_id: String,
43 #[serde(default)]
44 pub created_at_unix_ms: u64,
45 #[serde(default)]
46 pub event_range: BundleEventRange,
47 #[serde(default)]
48 pub abouts: Vec<String>,
49 #[serde(default)]
50 pub content_digest: String,
51}
52
53pub const BUNDLE_FORMAT_VERSION: u32 = 2;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct ImportReport {
58 pub events_imported: u64,
59 pub rebuild: ProjectionRebuildReport,
60}
61
62impl EmbeddedKernelStore {
63 pub fn export_bundle_blocking(&self) -> Result<String, PortError> {
66 encode_bundle(&self.read_event_log()?, None)
67 }
68
69 pub async fn export_bundle(&self) -> Result<String, PortError> {
72 self.run(|store| store.export_bundle_blocking()).await
73 }
74
75 pub async fn export_bundle_for_abouts(
84 &self,
85 requested_abouts: &[String],
86 ) -> Result<String, PortError> {
87 let events = self.run(EmbeddedKernelStore::read_event_log).await?;
88 let events = filter_events_for_abouts(events, requested_abouts)?;
89 encode_bundle(&events, None)
90 }
91
92 pub fn export_bundle_excluding_abouts_blocking(
106 &self,
107 excluded_abouts: &[String],
108 ) -> Result<String, PortError> {
109 let events = self.read_event_log()?;
110 encode_bundle(
111 &filter_events_excluding_abouts(events, excluded_abouts),
112 None,
113 )
114 }
115
116 pub async fn export_bundle_excluding_abouts(
118 &self,
119 excluded_abouts: &[String],
120 ) -> Result<String, PortError> {
121 let events = self.run(EmbeddedKernelStore::read_event_log).await?;
122 encode_bundle(
123 &filter_events_excluding_abouts(events, excluded_abouts),
124 None,
125 )
126 }
127
128 pub async fn export_named_bundle(&self, snapshot_id: &str) -> Result<String, PortError> {
132 if snapshot_id.trim().is_empty() {
133 return Err(PortError::InvalidState(
134 "snapshot id must not be empty".to_string(),
135 ));
136 }
137 let events = self.run(EmbeddedKernelStore::read_event_log).await?;
138 encode_bundle(&events, Some(snapshot_id))
139 }
140
141 pub async fn import_bundle<F>(&self, bundle: &str, derive: F) -> Result<ImportReport, PortError>
146 where
147 F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
148 {
149 let (log_length, _) = self.event_log_stats().await?;
150 if log_length != 0 {
151 return Err(PortError::Conflict(format!(
152 "import requires an empty store; this store already holds {log_length} events \
153 (merging bundles is not supported)"
154 )));
155 }
156
157 let verified = parse_bundle(bundle)?;
158 let header = verified.header;
159 let events = verified.events;
160 validate_revisions(&events)?;
161 for event in &events {
165 derive(event)?;
166 }
167 let events_imported = self.replay_event_stream(events).await?;
168 debug_assert_eq!(events_imported, header.event_count);
169
170 let rebuild = self.rebuild_projections(derive).await?;
171 Ok(ImportReport {
172 events_imported,
173 rebuild,
174 })
175 }
176}
177
178pub fn verify_bundle(bundle: &str) -> Result<BundleHeader, PortError> {
182 parse_bundle(bundle).map(|verified| verified.header)
183}
184
185pub fn merge_bundles(left: &str, right: &str, snapshot_id: &str) -> Result<String, PortError> {
189 if snapshot_id.trim().is_empty() {
190 return Err(PortError::InvalidState(
191 "merged snapshot id must not be empty".to_string(),
192 ));
193 }
194 let left = parse_bundle(left)?;
195 let right = parse_bundle(right)?;
196 let shared = left.events.len().min(right.events.len());
197 if let Some(position) =
198 (0..shared).find(|position| left.events[*position] != right.events[*position])
199 {
200 return Err(PortError::Conflict(format!(
201 "bundle histories diverge at event position {}; KMP only fast-forwards an exact \
202 prefix and will not invent causal order for two branches",
203 position + 1
204 )));
205 }
206 let events = if left.events.len() >= right.events.len() {
207 left.events
208 } else {
209 right.events
210 };
211 encode_bundle(&events, Some(snapshot_id))
212}
213
214struct VerifiedBundle {
215 header: BundleHeader,
216 events: Vec<ContextUpdatedEvent>,
217}
218
219fn parse_bundle(bundle: &str) -> Result<VerifiedBundle, PortError> {
220 let mut lines = bundle.lines().filter(|line| !line.trim().is_empty());
221 let header: BundleHeader = decode_line(
222 "bundle header",
223 lines.next().ok_or_else(|| {
224 PortError::InvalidState("bundle is empty: missing header line".to_string())
225 })?,
226 )?;
227 if !matches!(header.bundle_format, 1 | BUNDLE_FORMAT_VERSION) {
228 return Err(PortError::InvalidState(format!(
229 "bundle format {} is not supported (this binary reads 1 and {})",
230 header.bundle_format, BUNDLE_FORMAT_VERSION
231 )));
232 }
233 if header.event_format != super::format_version::EVENT_FORMAT_VERSION {
234 return Err(PortError::InvalidState(format!(
235 "bundle carries event format {}, this binary supports {}",
236 header.event_format,
237 super::format_version::EVENT_FORMAT_VERSION
238 )));
239 }
240
241 let mut events = Vec::new();
242 let mut event_payload = String::new();
243 for line in lines {
244 events.push(decode_line::<ContextUpdatedEvent>("bundle event", line)?);
245 event_payload.push_str(line);
246 event_payload.push('\n');
247 }
248 if events.len() as u64 != header.event_count {
249 return Err(PortError::InvalidState(format!(
250 "bundle header declares {} events but {} were present",
251 header.event_count,
252 events.len()
253 )));
254 }
255
256 if header.bundle_format == BUNDLE_FORMAT_VERSION {
257 validate_v2_header(&header, &events, &event_payload)?;
258 }
259 Ok(VerifiedBundle { header, events })
260}
261
262fn validate_v2_header(
263 header: &BundleHeader,
264 events: &[ContextUpdatedEvent],
265 event_payload: &str,
266) -> Result<(), PortError> {
267 if header.snapshot_id.trim().is_empty() {
268 return Err(PortError::InvalidState(
269 "bundle format 2 requires snapshot_id".to_string(),
270 ));
271 }
272 if header.created_at_unix_ms == 0 {
273 return Err(PortError::InvalidState(
274 "bundle format 2 requires created_at_unix_ms".to_string(),
275 ));
276 }
277 let expected_range = event_range(events.len());
278 if header.event_range != expected_range {
279 return Err(PortError::InvalidState(format!(
280 "bundle event_range {:?} does not cover its {} events (expected {:?})",
281 header.event_range,
282 events.len(),
283 expected_range
284 )));
285 }
286 let expected_abouts = abouts(events);
287 if header.abouts != expected_abouts {
288 return Err(PortError::InvalidState(format!(
289 "bundle abouts do not match its events (expected {})",
290 expected_abouts.join(", ")
291 )));
292 }
293 let expected_digest = content_digest(event_payload.as_bytes());
294 if header.content_digest != expected_digest {
295 return Err(PortError::InvalidState(format!(
296 "bundle content digest mismatch: header says {}, events produce {expected_digest}",
297 header.content_digest
298 )));
299 }
300 Ok(())
301}
302
303fn encode_bundle(
304 events: &[ContextUpdatedEvent],
305 snapshot_id: Option<&str>,
306) -> Result<String, PortError> {
307 let mut event_payload = String::new();
308 for event in events {
309 event_payload.push_str(&encode_line("bundle event", event)?);
310 }
311 let digest = content_digest(event_payload.as_bytes());
312 let named = snapshot_id.is_some();
313 let snapshot_id = snapshot_id
314 .map(str::to_string)
315 .unwrap_or_else(|| format!("content-{}", &digest[7..23]));
316 let created_at = if named {
321 SystemTime::now()
322 } else {
323 events
324 .iter()
325 .map(|event| event.occurred_at)
326 .max()
327 .unwrap_or(UNIX_EPOCH + Duration::from_millis(1))
328 };
329 let created_at_unix_ms = created_at
330 .duration_since(UNIX_EPOCH)
331 .unwrap_or(Duration::ZERO)
332 .as_millis() as u64;
333 let header = BundleHeader {
334 bundle_format: BUNDLE_FORMAT_VERSION,
335 event_format: super::format_version::EVENT_FORMAT_VERSION,
336 event_count: events.len() as u64,
337 kernel_version: env!("CARGO_PKG_VERSION").to_string(),
338 snapshot_id,
339 created_at_unix_ms,
340 event_range: event_range(events.len()),
341 abouts: abouts(events),
342 content_digest: digest,
343 };
344 let mut out = encode_line("bundle header", &header)?;
345 out.push_str(&event_payload);
346 Ok(out)
347}
348
349fn event_range(event_count: usize) -> BundleEventRange {
350 if event_count == 0 {
351 BundleEventRange::default()
352 } else {
353 BundleEventRange {
354 first: Some(1),
355 last: Some(event_count as u64),
356 }
357 }
358}
359
360fn abouts(events: &[ContextUpdatedEvent]) -> Vec<String> {
361 events
362 .iter()
363 .map(|event| event.root_node_id.clone())
364 .collect::<BTreeSet<_>>()
365 .into_iter()
366 .collect()
367}
368
369fn filter_events_excluding_abouts(
370 events: Vec<ContextUpdatedEvent>,
371 excluded_abouts: &[String],
372) -> Vec<ContextUpdatedEvent> {
373 if excluded_abouts.is_empty() {
374 return events;
375 }
376 let excluded = excluded_abouts.iter().cloned().collect::<BTreeSet<_>>();
377 events
378 .into_iter()
379 .filter(|event| !excluded.contains(&event.root_node_id))
380 .collect()
381}
382
383fn filter_events_for_abouts(
384 events: Vec<ContextUpdatedEvent>,
385 requested_abouts: &[String],
386) -> Result<Vec<ContextUpdatedEvent>, PortError> {
387 if requested_abouts.is_empty() {
388 return Err(PortError::InvalidState(
389 "filtered export requires at least one about".to_string(),
390 ));
391 }
392 let requested = requested_abouts.iter().cloned().collect::<BTreeSet<_>>();
393 let found = events
394 .iter()
395 .filter(|event| requested.contains(&event.root_node_id))
396 .map(|event| event.root_node_id.clone())
397 .collect::<BTreeSet<_>>();
398 let missing = requested.difference(&found).cloned().collect::<Vec<_>>();
399 if !missing.is_empty() {
400 return Err(PortError::InvalidState(format!(
401 "cannot export missing about{}: {}",
402 if missing.len() == 1 { "" } else { "s" },
403 missing
404 .iter()
405 .map(|about| format!("`{about}`"))
406 .collect::<Vec<_>>()
407 .join(", ")
408 )));
409 }
410 Ok(events
411 .into_iter()
412 .filter(|event| requested.contains(&event.root_node_id))
413 .collect())
414}
415
416fn content_digest(bytes: &[u8]) -> String {
417 format!("sha256:{:x}", Sha256::digest(bytes))
418}
419
420fn validate_revisions(events: &[ContextUpdatedEvent]) -> Result<(), PortError> {
421 let mut revisions: BTreeMap<(&str, &str), u64> = BTreeMap::new();
422 for (position, event) in events.iter().enumerate() {
423 let previous = revisions
424 .get(&(event.root_node_id.as_str(), event.role.as_str()))
425 .copied()
426 .unwrap_or(0);
427 let expected = previous + 1;
428 if event.revision != expected {
429 return Err(PortError::InvalidState(format!(
430 "bundle event position {} carries revision {} for ({}, {}), expected {}; no \
431 events were imported",
432 position + 1,
433 event.revision,
434 event.root_node_id,
435 event.role,
436 expected
437 )));
438 }
439 revisions.insert(
440 (event.root_node_id.as_str(), event.role.as_str()),
441 event.revision,
442 );
443 }
444 Ok(())
445}
446
447impl EmbeddedKernelStore {
448 pub(crate) async fn replay_event_stream<I>(&self, events: I) -> Result<u64, PortError>
456 where
457 I: IntoIterator<Item = ContextUpdatedEvent>,
458 {
459 let mut replayed = 0u64;
460 for event in events {
461 let recorded_revision = event.revision;
462 let expected_previous = recorded_revision.checked_sub(1).ok_or_else(|| {
463 PortError::InvalidState("event carries revision 0; the log is corrupt".to_string())
464 })?;
465 let assigned = self.append(event, expected_previous).await?;
466 if assigned != recorded_revision {
467 return Err(PortError::Conflict(format!(
468 "replay integrity violation: assigned revision {assigned}, \
469 history recorded {recorded_revision}"
470 )));
471 }
472 replayed += 1;
473 }
474 Ok(replayed)
475 }
476}
477
478fn encode_line<T: Serialize>(what: &str, value: &T) -> Result<String, PortError> {
479 let mut line = serde_json::to_string(value)
480 .map_err(|error| PortError::InvalidState(format!("could not encode {what}: {error}")))?;
481 line.push('\n');
482 Ok(line)
483}
484
485fn decode_line<T: for<'de> Deserialize<'de>>(what: &str, line: &str) -> Result<T, PortError> {
486 serde_json::from_str(line)
487 .map_err(|error| PortError::InvalidState(format!("could not decode {what}: {error}")))
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 fn event(root: &str, revision: u64, content_hash: &str) -> ContextUpdatedEvent {
495 ContextUpdatedEvent {
496 root_node_id: root.to_string(),
497 role: "agent".to_string(),
498 revision,
499 content_hash: content_hash.to_string(),
500 changes: Vec::new(),
501 idempotency_key: Some(format!("{root}:{revision}")),
502 logical_digest: None,
503 requested_by: Some("portability-test".to_string()),
504 occurred_at: UNIX_EPOCH + Duration::from_secs(revision),
505 }
506 }
507
508 #[test]
509 fn format_two_identifies_and_covers_the_snapshot() {
510 let events = vec![event("project:b", 1, "b"), event("project:a", 1, "a")];
511 let bundle = encode_bundle(&events, Some("pre-release")).expect("bundle");
512 let header = verify_bundle(&bundle).expect("verified");
513
514 assert_eq!(header.bundle_format, BUNDLE_FORMAT_VERSION);
515 assert_eq!(
516 header.event_format,
517 super::super::format_version::EVENT_FORMAT_VERSION
518 );
519 assert_eq!(header.snapshot_id, "pre-release");
520 assert!(header.created_at_unix_ms > 0);
521 assert_eq!(
522 header.event_range,
523 BundleEventRange {
524 first: Some(1),
525 last: Some(2),
526 }
527 );
528 assert_eq!(header.abouts, ["project:a", "project:b"]);
529 assert!(header.content_digest.starts_with("sha256:"));
530 }
531
532 #[test]
533 fn exclusion_keeps_everything_the_excluded_abouts_do_not_root() {
534 let events = vec![
535 event("project:a", 1, "a1"),
536 event("guide:kmp", 1, "g1"),
537 event("project:a", 2, "a2"),
538 event("guide:kmp-agent", 1, "g2"),
539 ];
540 let excluded = vec!["guide:kmp".to_string(), "guide:kmp-agent".to_string()];
541
542 let kept = filter_events_excluding_abouts(events, &excluded);
543
544 assert_eq!(
545 kept.iter()
546 .map(|event| (event.root_node_id.as_str(), event.revision))
547 .collect::<Vec<_>>(),
548 vec![("project:a", 1), ("project:a", 2)]
549 );
550 }
551
552 #[test]
553 fn exclusion_matches_exactly_because_abouts_are_opaque() {
554 let events = vec![
555 event("guide:kmp", 1, "g1"),
556 event("guide:kmp:extra", 1, "x1"),
557 event(" guide:kmp", 1, "s1"),
558 ];
559 let excluded = vec!["guide:kmp".to_string()];
560
561 let kept = filter_events_excluding_abouts(events, &excluded);
562
563 assert_eq!(
564 kept.iter()
565 .map(|event| event.root_node_id.as_str())
566 .collect::<Vec<_>>(),
567 vec!["guide:kmp:extra", " guide:kmp"]
568 );
569 }
570
571 #[test]
572 fn excluding_an_about_the_store_never_had_is_not_an_error() {
573 let events = vec![event("project:a", 1, "a1")];
577 let excluded = vec!["guide:kmp".to_string()];
578
579 let kept = filter_events_excluding_abouts(events, &excluded);
580
581 assert_eq!(kept.len(), 1);
582 assert_eq!(kept[0].root_node_id, "project:a");
583 }
584
585 #[test]
586 fn filtered_export_matches_opaque_abouts_exactly_and_renumbers_its_range() {
587 let events = vec![
588 event("project:a", 1, "a1"),
589 event("project:ab", 1, "ab1"),
590 event("project:a", 2, "a2"),
591 ];
592 let filtered = filter_events_for_abouts(events, &["project:a".to_string()])
593 .expect("exact about exists");
594 assert_eq!(filtered.len(), 2);
595 assert!(
596 filtered
597 .iter()
598 .all(|event| event.root_node_id == "project:a")
599 );
600
601 let bundle = encode_bundle(&filtered, None).expect("filtered bundle");
602 let header = verify_bundle(&bundle).expect("filtered bundle verifies");
603 assert_eq!(header.abouts, ["project:a"]);
604 assert_eq!(header.event_count, 2);
605 assert_eq!(
606 header.event_range,
607 BundleEventRange {
608 first: Some(1),
609 last: Some(2),
610 }
611 );
612 }
613
614 #[test]
615 fn filtered_export_names_every_requested_about_that_is_missing() {
616 let error = filter_events_for_abouts(
617 vec![event("project:a", 1, "a")],
618 &["project:a".into(), "project:none".into()],
619 )
620 .expect_err("missing about must fail");
621 assert!(error.to_string().contains("`project:none`"), "{error}");
622 }
623
624 #[test]
625 fn tampering_is_rejected_before_a_bundle_can_be_replayed() {
626 let bundle =
627 encode_bundle(&[event("project:a", 1, "before")], Some("saved")).expect("bundle");
628 let tampered = bundle.replace("\"content_hash\":\"before\"", "\"content_hash\":\"after\"");
629 let error = verify_bundle(&tampered).expect_err("digest catches changed payload");
630 assert!(error.to_string().contains("content digest mismatch"));
631 }
632
633 #[test]
634 fn merge_fast_forwards_an_exact_prefix() {
635 let first = event("project:a", 1, "one");
636 let second = event("project:a", 2, "two");
637 let left = encode_bundle(std::slice::from_ref(&first), Some("left")).expect("left");
638 let right = encode_bundle(&[first, second], Some("right")).expect("right");
639
640 let merged = merge_bundles(&left, &right, "merged").expect("fast forward");
641 let header = verify_bundle(&merged).expect("verified merge");
642 assert_eq!(header.snapshot_id, "merged");
643 assert_eq!(header.event_count, 2);
644 }
645
646 #[test]
647 fn merge_refuses_two_histories_at_the_same_position() {
648 let left = encode_bundle(&[event("project:a", 1, "left")], Some("left")).expect("left");
649 let right = encode_bundle(&[event("project:a", 1, "right")], Some("right")).expect("right");
650
651 let error = merge_bundles(&left, &right, "invented").expect_err("must refuse");
652 assert!(error.to_string().contains("diverge at event position 1"));
653 assert!(error.to_string().contains("will not invent causal order"));
654 }
655
656 #[test]
657 fn legacy_format_one_remains_readable() {
658 let legacy =
659 r#"{"bundle_format":1,"store_format":1,"event_count":0,"kernel_version":"0.1.3"}"#;
660 let header = verify_bundle(legacy).expect("format one remains portable");
661 assert_eq!(header.event_format, 1);
662 assert!(header.snapshot_id.is_empty());
663 }
664
665 #[test]
666 fn invalid_later_revision_is_rejected_in_preflight() {
667 let events = [event("project:a", 1, "one"), event("project:a", 3, "three")];
668 let error = validate_revisions(&events).expect_err("revision gap");
669 assert!(error.to_string().contains("position 2"));
670 assert!(error.to_string().contains("no events were imported"));
671 }
672}