1use serde::Serialize;
30
31use super::{Engine, EngineError};
32use crate::workspace::MountStorage;
33
34pub const HISTORY_PAGE_DEFAULT: usize = 50;
36pub const HISTORY_PAGE_MAX: usize = 200;
38
39#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
41pub struct EntityTouch {
42 pub reference: String,
46 pub timestamp: i64,
48 pub id_at_touch: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
56 pub verb: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub subject: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub note: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub actor: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub client: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub tool: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
74 pub renamed_from: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub renamed_to: Option<String>,
78 #[serde(skip_serializing_if = "Vec::is_empty", default)]
82 pub batch_entity_ids: Vec<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub logical_op: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
90 pub role: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
95 pub identity: Option<String>,
96}
97
98#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
101#[serde(tag = "kind", rename_all = "lowercase")]
102pub enum StoryStart {
103 Recorded,
106 Truncated { reason: String },
110}
111
112#[derive(Debug, Clone, Serialize)]
115pub struct EntityHistoryReport {
116 pub mem: String,
117 pub entity_id: String,
119 pub touches: Vec<EntityTouch>,
121 pub total_recorded: usize,
124 #[serde(skip_serializing_if = "Option::is_none")]
127 pub next_cursor: Option<String>,
128 pub story_start: StoryStart,
130 #[serde(skip_serializing_if = "Vec::is_empty", default)]
133 pub limitations: Vec<String>,
134}
135
136fn parse_rename_pair(field: &str) -> Option<(String, String)> {
141 if field.contains("(cross-mem rewrite") {
142 return None;
143 }
144 let mut parts = field.splitn(2, " → ");
145 let old = parts.next()?.trim();
146 let new = parts.next()?.trim();
147 if old.is_empty() || new.is_empty() {
148 return None;
149 }
150 Some((old.to_string(), new.to_string()))
151}
152
153#[derive(Debug, Clone, serde::Serialize)]
157pub struct ProvenanceRecord {
158 #[serde(skip_serializing_if = "Option::is_none")]
161 pub actor: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub client: Option<String>,
165 pub role: String,
169 #[serde(skip_serializing_if = "Option::is_none")]
173 pub identity: Option<String>,
174 pub timestamp: i64,
176 pub reference: String,
178}
179
180#[derive(Debug, Clone, serde::Serialize)]
184pub struct EntityProvenance {
185 #[serde(skip_serializing_if = "Option::is_none")]
189 pub created_by: Option<ProvenanceRecord>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub last_modified_by: Option<ProvenanceRecord>,
193 #[serde(skip_serializing_if = "std::ops::Not::not")]
196 pub story_truncated: bool,
197 pub check_state: String,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub last_check: Option<crate::check::CheckRecord>,
206 pub conformance_state: String,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub last_conformance_check: Option<crate::check::CheckRecord>,
214}
215
216fn touch_to_record(t: &EntityTouch) -> ProvenanceRecord {
217 ProvenanceRecord {
218 actor: t.actor.clone(),
219 client: t.client.clone(),
220 role: t.role.clone().unwrap_or_else(|| "unspecified".to_string()),
221 identity: t.identity.clone(),
222 timestamp: t.timestamp,
223 reference: t.reference.clone(),
224 }
225}
226
227impl Engine {
228 pub fn entity_provenance(
237 &self,
238 mem: &str,
239 entity_id: &str,
240 ) -> Result<EntityProvenance, EngineError> {
241 let mut report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), None)?;
242 let newest = report.touches.first().cloned();
243 while let Some(cursor) = report.next_cursor.clone() {
245 report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), Some(&cursor))?;
246 }
247 let oldest = report.touches.last().or(newest.as_ref()).cloned();
248 let truncated = !matches!(report.story_start, StoryStart::Recorded);
249 let (check_state, last_check) = self.entity_check_state(mem, entity_id)?;
250 let (conformance_state, last_conformance_check) =
251 self.entity_conformance_state(mem, entity_id)?;
252 Ok(EntityProvenance {
253 created_by: match (&oldest, truncated) {
254 (Some(t), false) => Some(touch_to_record(t)),
255 _ => None,
256 },
257 last_modified_by: newest.as_ref().map(touch_to_record),
258 story_truncated: truncated,
259 check_state: check_state.as_str().to_string(),
260 last_check,
261 conformance_state: conformance_state.as_str().to_string(),
262 last_conformance_check,
263 })
264 }
265
266 pub fn entity_history(
277 &self,
278 mem: &str,
279 entity_id: &str,
280 page_size: Option<usize>,
281 cursor: Option<&str>,
282 ) -> Result<EntityHistoryReport, EngineError> {
283 let m = self.find_mount(mem)?;
284
285 let known = self
289 .store
290 .all_entities()
291 .any(|e| e.mem == mem && e.id.0 == entity_id);
292 if !known {
293 return Err(EngineError::NotFound {
294 id: entity_id.to_string(),
295 });
296 }
297
298 let mut limitations: Vec<String> = Vec::new();
299
300 let touches: Vec<EntityTouch> = match &m.mount.storage {
302 MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
303 Some(hook) => {
304 let backend_changes = (hook.changes_since)(
305 gitdir,
306 branch,
307 mem,
308 crate::ops::EMPTY_TREE_SHA,
309 crate::ops::RENAME_SIMILARITY_DEFAULT,
310 )
311 .map_err(EngineError::Backend)?;
312 filter_notes_for_entity(entity_id, &backend_changes.notes)
313 }
314 None => {
315 limitations.push(
316 "this build carries no git-branch history walk — the recorded story \
317 is not reachable"
318 .to_string(),
319 );
320 Vec::new()
321 }
322 },
323 MountStorage::Folder { .. } | MountStorage::InMemory => {
324 limitations.push(
325 "changelog-backed history: rename records carry only the post-rename id \
326 (prior-id chains are not stitchable) and batch mutations record no \
327 per-entity attribution on this backend"
328 .to_string(),
329 );
330 let records = m
331 .backend
332 .read_provenance(None)
333 .map_err(EngineError::Backend)?;
334 filter_provenance_for_entity(entity_id, &records)
335 }
336 MountStorage::Archive { .. } => {
337 return Err(EngineError::InvalidInput(format!(
338 "mem '{mem}' is an archive mount — archives record no history at the \
339 engine seam; open the source mem for the entity's story"
340 )));
341 }
342 };
343
344 let story_start = match touches.last() {
349 Some(oldest)
357 if matches!(
358 oldest.verb.as_deref(),
359 Some("create") | Some("batch-create")
360 ) =>
361 {
362 StoryStart::Recorded
363 }
364 Some(oldest) => StoryStart::Truncated {
365 reason: format!(
366 "oldest recorded touch is a {} of `{}`, not the entity's creation — \
367 earlier history (an unrecorded prior id, or records predating the \
368 provenance log) is not reachable",
369 oldest.verb.as_deref().unwrap_or("touch"),
370 oldest.id_at_touch
371 ),
372 },
373 None => StoryStart::Truncated {
374 reason: "no touches recorded — the entity predates this mem's provenance \
375 record (an adopted or migrated mem)"
376 .to_string(),
377 },
378 };
379
380 let size = page_size
382 .unwrap_or(HISTORY_PAGE_DEFAULT)
383 .clamp(1, HISTORY_PAGE_MAX);
384 let start_idx = match cursor {
385 None => 0,
386 Some(c) => {
387 let pos = parse_cursor(c).and_then(|(reference, k)| {
388 touches
389 .iter()
390 .enumerate()
391 .filter(|(_, t)| t.reference == reference)
392 .nth(k)
393 .map(|(i, _)| i + 1)
394 });
395 pos.ok_or_else(|| EngineError::InvalidChangesCursor {
396 mem: mem.to_string(),
397 since: c.to_string(),
398 })?
399 }
400 };
401 let total_recorded = touches.len();
402 let page: Vec<EntityTouch> = touches[start_idx.min(total_recorded)..]
403 .iter()
404 .take(size)
405 .cloned()
406 .collect();
407 let next_cursor = if start_idx + page.len() < total_recorded {
408 page.last().map(|last| {
409 let k = touches[..start_idx + page.len()]
410 .iter()
411 .filter(|t| t.reference == last.reference)
412 .count()
413 - 1;
414 format!("{}@{k}", last.reference)
415 })
416 } else {
417 None
418 };
419
420 Ok(EntityHistoryReport {
421 mem: mem.to_string(),
422 entity_id: entity_id.to_string(),
423 touches: page,
424 total_recorded,
425 next_cursor,
426 story_start,
427 limitations,
428 })
429 }
430}
431
432fn parse_cursor(c: &str) -> Option<(String, usize)> {
437 let (reference, k) = c.rsplit_once('@')?;
438 if reference.is_empty() {
439 return None;
440 }
441 Some((reference.to_string(), k.parse().ok()?))
442}
443
444fn filter_notes_for_entity(
459 entity_id: &str,
460 notes: &[crate::ops::agent_notes::CommitNote],
461) -> Vec<EntityTouch> {
462 let mut current = entity_id.to_string();
463 let mut out: Vec<EntityTouch> = Vec::new();
464 for n in notes {
465 let base = |id_at: &str| EntityTouch {
466 reference: n.sha.clone(),
467 timestamp: n.timestamp,
468 id_at_touch: id_at.to_string(),
469 verb: n.tool_verb.clone(),
470 subject: Some(n.subject.clone()),
471 note: n.note.clone(),
472 actor: n.actor.clone(),
473 client: n.client.clone(),
474 tool: n.tool.clone(),
475 renamed_from: None,
476 renamed_to: None,
477 batch_entity_ids: Vec::new(),
478 logical_op: n.logical_operation_id.clone(),
479 role: n.role.clone(),
480 identity: n.identity.clone(),
481 };
482 if n.tool_verb.as_deref() == Some("rename") {
483 if let Some((old, new)) = n.entity_id.as_deref().and_then(parse_rename_pair)
484 && new == current
485 {
486 let mut touch = base(&new);
487 touch.renamed_from = Some(old.clone());
488 touch.renamed_to = Some(new);
489 out.push(touch);
490 current = old;
491 }
492 continue;
493 }
494 if n.entity_id.as_deref() == Some(current.as_str()) {
495 let is_create = n.tool_verb.as_deref() == Some("create");
496 out.push(base(¤t));
497 if is_create {
498 break;
499 }
500 } else if n.entity_ids.iter().any(|id| id == ¤t) {
501 let mut touch = base(¤t);
502 touch.batch_entity_ids = n.entity_ids.clone();
503 out.push(touch);
504 if n.tool_verb.as_deref() == Some("batch-create") {
507 break;
508 }
509 }
510 }
511 out
512}
513
514fn filter_provenance_for_entity(
525 entity_id: &str,
526 records: &[crate::provenance::Provenance],
527) -> Vec<EntityTouch> {
528 let matching: Vec<&crate::provenance::Provenance> = records
529 .iter()
530 .filter(|p| p.entity.as_deref() == Some(entity_id))
531 .collect();
532 let birth = matching
533 .iter()
534 .rposition(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Create))
535 .unwrap_or(0);
536 let mut out: Vec<EntityTouch> = matching[birth..]
537 .iter()
538 .map(|p| {
539 let is_rename = matches!(p.kind, crate::provenance::ProvenanceKind::Rename);
540 EntityTouch {
541 reference: crate::filesystem::changelog::format_rfc3339_utc(p.timestamp),
542 timestamp: p
543 .timestamp
544 .duration_since(std::time::UNIX_EPOCH)
545 .map(|d| d.as_secs() as i64)
546 .unwrap_or(0),
547 id_at_touch: entity_id.to_string(),
548 verb: Some(p.kind.as_str().to_string()),
549 subject: None,
550 note: p.note.clone(),
551 actor: Some(p.actor.as_trailer().to_string()),
552 client: p
553 .client
554 .as_ref()
555 .map(|c| format!("{}@{}", c.name, c.version)),
556 tool: None,
557 renamed_from: None,
558 renamed_to: is_rename.then(|| entity_id.to_string()),
559 batch_entity_ids: Vec::new(),
560 logical_op: p.logical_operation_id.clone(),
561 role: p.role.as_trailer().map(str::to_string),
562 identity: p.identity.clone(),
563 }
564 })
565 .collect();
566 out.reverse();
567 out
568}
569
570#[cfg(test)]
571mod tests {
572 use crate::storage::MemWriter;
573
574 #[test]
582 fn batch_create_note_is_the_entitys_creation() {
583 use crate::ops::agent_notes::CommitNote;
584 let note = |sha: &str, verb: &str, entity_id: Option<&str>, ids: Vec<&str>| CommitNote {
585 mem: "m".into(),
586 sha: sha.into(),
587 subject: format!("memstead: {verb}"),
588 tool_verb: Some(verb.into()),
589 entity_id: entity_id.map(str::to_string),
590 note: None,
591 actor: Some("cli".into()),
592 tool: None,
593 client: None,
594 logical_operation_id: None,
595 role: Some("author".into()),
596 identity: Some("author-x".into()),
597 entity_ids: ids.into_iter().map(str::to_string).collect(),
598 timestamp: 1,
599 };
600 let notes = vec![
603 note("c3", "update", Some("m--thing"), vec![]),
604 note("c2", "batch-create", None, vec!["m--other", "m--thing"]),
605 note("c1", "update", Some("m--thing"), vec![]),
606 ];
607 let touches = super::filter_notes_for_entity("m--thing", ¬es);
608 assert_eq!(
609 touches.len(),
610 2,
611 "the walk stops at the batch create: {touches:?}"
612 );
613 let oldest = touches.last().unwrap();
614 assert_eq!(oldest.verb.as_deref(), Some("batch-create"));
615 assert_eq!(oldest.identity.as_deref(), Some("author-x"));
616 assert!(
617 matches!(
618 oldest.verb.as_deref(),
619 Some("create") | Some("batch-create")
620 ),
621 "the story-start acceptance covers the batch verb"
622 );
623 }
624
625 const SEED: &str = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Seed\n\n## Identity\n\nSeed.\n";
626
627 fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
631 let dir = tmp.path().join("specs");
632 if !dir.exists() {
633 std::fs::create_dir_all(&dir).unwrap();
634 let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
635 MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
636 .unwrap();
637 MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
638 }
639 let mount = crate::Mount {
640 mem: "specs".to_string(),
641 schema: Some(memstead_schema::SchemaRef::new(
642 "default",
643 semver::Version::new(1, 0, 0),
644 )),
645 storage: crate::MountStorage::Folder { path: dir.clone() },
646 capability: crate::MountCapability::Write,
647 lifecycle: crate::MountLifecycle::Eager,
648 cross_linkable: false,
649 migration_target: None,
650 };
651 let backend =
652 Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
653 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
654 }
655
656 fn create(engine: &mut crate::Engine, title: &str, note: &str) -> String {
657 let outcome = engine
658 .create_entity(
659 crate::CreateEntityArgs {
660 mem: "specs".to_string(),
661 title: title.to_string(),
662 entity_type: "spec".to_string(),
663 sections: [
664 ("identity".to_string(), "x".to_string()),
665 ("purpose".to_string(), "y".to_string()),
666 ]
667 .into_iter()
668 .collect(),
669 metadata: Default::default(),
670 relations: Vec::new(),
671 anchors: Vec::new(),
672 dry_run: false,
673 },
674 crate::vcs::Actor::Cli,
675 None,
676 Some(note),
677 )
678 .unwrap();
679 outcome.id.0
680 }
681
682 fn update(engine: &mut crate::Engine, id: &str, note: &str) {
683 engine
684 .update_entity(
685 crate::UpdateEntityArgs {
686 id: crate::EntityId(id.to_string()),
687 expected_hash: None,
688 sections: [("identity".to_string(), format!("touched: {note}"))]
689 .into_iter()
690 .collect(),
691 append_sections: Default::default(),
692 patch_sections: Default::default(),
693 metadata: Default::default(),
694 metadata_unset: Vec::new(),
695 dry_run: false,
696 declare_relations: Vec::new(),
697 anchors: Vec::new(),
698 relations_unset: Vec::new(),
699 anchors_unset: Vec::new(),
700 },
701 crate::vcs::Actor::App,
702 None,
703 Some(note),
704 )
705 .unwrap();
706 }
707
708 #[test]
709 fn folder_history_serves_touches_with_stated_limitations() {
710 let tmp = tempfile::TempDir::new().unwrap();
711 let mut engine = folder_engine(&tmp);
712 let id = create(&mut engine, "Story", "born");
713 update(&mut engine, &id, "grew");
714
715 let report = engine.entity_history("specs", &id, None, None).unwrap();
716 assert_eq!(report.total_recorded, 2);
717 assert_eq!(report.touches.len(), 2);
718 assert_eq!(report.touches[0].verb.as_deref(), Some("update"));
720 assert_eq!(report.touches[0].note.as_deref(), Some("grew"));
721 assert_eq!(report.touches[0].actor.as_deref(), Some("app"));
722 assert_eq!(report.touches[1].verb.as_deref(), Some("create"));
723 assert_eq!(report.touches[1].actor.as_deref(), Some("cli"));
724 assert_eq!(report.story_start, super::StoryStart::Recorded);
725 assert!(
726 report
727 .limitations
728 .iter()
729 .any(|l| l.contains("rename records carry only the post-rename id")),
730 "folder limitations must be stated: {:?}",
731 report.limitations
732 );
733 assert!(report.touches.iter().all(|t| t.id_at_touch == id));
735 }
736
737 #[test]
738 fn folder_rename_truncates_visibly_not_silently() {
739 let tmp = tempfile::TempDir::new().unwrap();
740 let mut engine = folder_engine(&tmp);
741 let id = create(&mut engine, "Before Rename", "born");
742 let outcome = engine
743 .rename_entity(
744 crate::RenameEntityArgs {
745 id: crate::EntityId(id.clone()),
746 expected_hash: None,
747 new_title: "After Rename".to_string(),
748 },
749 crate::vcs::Actor::Cli,
750 None,
751 Some("renamed"),
752 )
753 .unwrap();
754 let new_id = outcome.new_id.0;
755
756 let report = engine.entity_history("specs", &new_id, None, None).unwrap();
760 assert_eq!(report.touches[0].verb.as_deref(), Some("rename"));
761 assert!(report.touches[0].renamed_from.is_none());
762 match &report.story_start {
763 super::StoryStart::Truncated { reason } => {
764 assert!(
765 reason.contains("not the entity's creation"),
766 "reason must explain the truncation: {reason}"
767 );
768 }
769 other => panic!("expected visible truncation, got {other:?}"),
770 }
771 }
772
773 #[test]
774 fn reused_id_never_absorbs_the_previous_holders_story() {
775 let tmp = tempfile::TempDir::new().unwrap();
781 let mut engine = folder_engine(&tmp);
782 let id = create(&mut engine, "Slot", "first holder");
783 update(&mut engine, &id, "first holder grew");
784 engine
785 .rename_entity(
786 crate::RenameEntityArgs {
787 id: crate::EntityId(id.clone()),
788 expected_hash: None,
789 new_title: "Slot Moved".to_string(),
790 },
791 crate::vcs::Actor::Cli,
792 None,
793 Some("moved away"),
794 )
795 .unwrap();
796 let reused = create(&mut engine, "Slot", "second holder");
797 assert_eq!(reused, id, "the slug is reused");
798
799 let report = engine.entity_history("specs", &reused, None, None).unwrap();
800 assert_eq!(report.total_recorded, 1, "only its own birth: {report:#?}");
801 assert_eq!(report.touches[0].verb.as_deref(), Some("create"));
802 assert_eq!(report.touches[0].note.as_deref(), Some("second holder"));
803 assert!(matches!(report.story_start, super::StoryStart::Recorded));
804 }
805
806 #[test]
807 fn refusals_are_typed_never_empty_stories() {
808 let tmp = tempfile::TempDir::new().unwrap();
809 let mut engine = folder_engine(&tmp);
810 let id = create(&mut engine, "Real", "born");
811
812 let err = engine.entity_history("ghost", &id, None, None).unwrap_err();
814 assert_eq!(err.code(), "UNKNOWN_MEM");
815 let err = engine
817 .entity_history("specs", "specs--nope", None, None)
818 .unwrap_err();
819 assert_eq!(err.code(), "ENTITY_NOT_FOUND");
820 let err = engine
822 .entity_history("specs", &id, None, Some("zzz@0"))
823 .unwrap_err();
824 assert_eq!(err.code(), "INVALID_CURSOR");
825 let err = engine
826 .entity_history("specs", &id, None, Some("no-separator"))
827 .unwrap_err();
828 assert_eq!(err.code(), "INVALID_CURSOR");
829 }
830
831 #[test]
832 fn pre_changelog_entity_states_the_empty_record() {
833 let tmp = tempfile::TempDir::new().unwrap();
838 let engine = folder_engine(&tmp);
839 let report = engine
840 .entity_history("specs", "specs--seed", None, None)
841 .unwrap();
842 assert!(report.touches.is_empty());
843 assert!(matches!(
844 report.story_start,
845 super::StoryStart::Truncated { .. }
846 ));
847 }
848
849 #[test]
850 fn archive_mounts_refuse_rather_than_fabricate_emptiness() {
851 let dir = tempfile::TempDir::new().unwrap();
856 let backend = crate::storage::InMemoryBackend::new();
857 crate::backend::MemBackend::write_entity(
858 &backend,
859 std::path::Path::new("seed.md"),
860 SEED.as_bytes(),
861 )
862 .unwrap();
863 crate::backend::MemBackend::commit(
864 &backend,
865 "seed",
866 &crate::vcs::CommitContext::internal(),
867 )
868 .unwrap();
869 let mount = crate::Mount {
870 mem: "specs".to_string(),
871 schema: Some(memstead_schema::SchemaRef::new(
872 "default",
873 semver::Version::new(1, 0, 0),
874 )),
875 storage: crate::MountStorage::Archive {
876 path: dir.path().join("sealed.mem"),
877 },
878 capability: crate::MountCapability::Write,
879 lifecycle: crate::MountLifecycle::Eager,
880 cross_linkable: false,
881 migration_target: None,
882 };
883 let engine = crate::Engine::from_mounts(vec![(
884 mount,
885 Box::new(backend) as Box<dyn crate::MemBackend>,
886 )])
887 .unwrap();
888 let err = engine
889 .entity_history("specs", "specs--seed", None, None)
890 .unwrap_err();
891 assert_eq!(err.code(), "INVALID_INPUT");
892 }
893
894 #[test]
895 fn pages_compose_without_gaps_or_duplicates() {
896 let tmp = tempfile::TempDir::new().unwrap();
897 let mut engine = folder_engine(&tmp);
898 let id = create(&mut engine, "Paged", "born");
899 for i in 0..5 {
900 update(&mut engine, &id, &format!("touch {i}"));
901 }
902
903 let full = engine.entity_history("specs", &id, None, None).unwrap();
904 assert_eq!(full.total_recorded, 6);
905 assert!(full.next_cursor.is_none());
906
907 let mut collected: Vec<super::EntityTouch> = Vec::new();
909 let mut cursor: Option<String> = None;
910 loop {
911 let page = engine
912 .entity_history("specs", &id, Some(2), cursor.as_deref())
913 .unwrap();
914 assert!(page.touches.len() <= 2);
915 assert_eq!(page.total_recorded, 6, "every page states the whole");
916 collected.extend(page.touches.clone());
917 match page.next_cursor {
918 Some(c) => cursor = Some(c),
919 None => break,
920 }
921 }
922 assert_eq!(
923 collected, full.touches,
924 "paged walk must equal the single-page story exactly"
925 );
926 }
927}