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}
92
93#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
96#[serde(tag = "kind", rename_all = "lowercase")]
97pub enum StoryStart {
98 Recorded,
101 Truncated { reason: String },
105}
106
107#[derive(Debug, Clone, Serialize)]
110pub struct EntityHistoryReport {
111 pub mem: String,
112 pub entity_id: String,
114 pub touches: Vec<EntityTouch>,
116 pub total_recorded: usize,
119 #[serde(skip_serializing_if = "Option::is_none")]
122 pub next_cursor: Option<String>,
123 pub story_start: StoryStart,
125 #[serde(skip_serializing_if = "Vec::is_empty", default)]
128 pub limitations: Vec<String>,
129}
130
131fn parse_rename_pair(field: &str) -> Option<(String, String)> {
136 if field.contains("(cross-mem rewrite") {
137 return None;
138 }
139 let mut parts = field.splitn(2, " → ");
140 let old = parts.next()?.trim();
141 let new = parts.next()?.trim();
142 if old.is_empty() || new.is_empty() {
143 return None;
144 }
145 Some((old.to_string(), new.to_string()))
146}
147
148#[derive(Debug, Clone, serde::Serialize)]
152pub struct ProvenanceRecord {
153 #[serde(skip_serializing_if = "Option::is_none")]
156 pub actor: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub client: Option<String>,
160 pub role: String,
164 pub timestamp: i64,
166 pub reference: String,
168}
169
170#[derive(Debug, Clone, serde::Serialize)]
174pub struct EntityProvenance {
175 #[serde(skip_serializing_if = "Option::is_none")]
179 pub created_by: Option<ProvenanceRecord>,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub last_modified_by: Option<ProvenanceRecord>,
183 #[serde(skip_serializing_if = "std::ops::Not::not")]
186 pub story_truncated: bool,
187 pub check_state: String,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub last_check: Option<crate::check::CheckRecord>,
195}
196
197fn touch_to_record(t: &EntityTouch) -> ProvenanceRecord {
198 ProvenanceRecord {
199 actor: t.actor.clone(),
200 client: t.client.clone(),
201 role: t.role.clone().unwrap_or_else(|| "unspecified".to_string()),
202 timestamp: t.timestamp,
203 reference: t.reference.clone(),
204 }
205}
206
207impl Engine {
208 pub fn entity_provenance(
217 &self,
218 mem: &str,
219 entity_id: &str,
220 ) -> Result<EntityProvenance, EngineError> {
221 let mut report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), None)?;
222 let newest = report.touches.first().cloned();
223 while let Some(cursor) = report.next_cursor.clone() {
225 report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), Some(&cursor))?;
226 }
227 let oldest = report.touches.last().or(newest.as_ref()).cloned();
228 let truncated = !matches!(report.story_start, StoryStart::Recorded);
229 let (check_state, last_check) = self.entity_check_state(mem, entity_id)?;
230 Ok(EntityProvenance {
231 created_by: match (&oldest, truncated) {
232 (Some(t), false) => Some(touch_to_record(t)),
233 _ => None,
234 },
235 last_modified_by: newest.as_ref().map(touch_to_record),
236 story_truncated: truncated,
237 check_state: check_state.as_str().to_string(),
238 last_check,
239 })
240 }
241
242 pub fn entity_history(
253 &self,
254 mem: &str,
255 entity_id: &str,
256 page_size: Option<usize>,
257 cursor: Option<&str>,
258 ) -> Result<EntityHistoryReport, EngineError> {
259 let m = self.find_mount(mem)?;
260
261 let known = self
265 .store
266 .all_entities()
267 .any(|e| e.mem == mem && e.id.0 == entity_id);
268 if !known {
269 return Err(EngineError::NotFound {
270 id: entity_id.to_string(),
271 });
272 }
273
274 let mut limitations: Vec<String> = Vec::new();
275
276 let touches: Vec<EntityTouch> = match &m.mount.storage {
278 MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
279 Some(hook) => {
280 let backend_changes = (hook.changes_since)(
281 gitdir,
282 branch,
283 mem,
284 crate::ops::EMPTY_TREE_SHA,
285 crate::ops::RENAME_SIMILARITY_DEFAULT,
286 )
287 .map_err(EngineError::Backend)?;
288 filter_notes_for_entity(entity_id, &backend_changes.notes)
289 }
290 None => {
291 limitations.push(
292 "this build carries no git-branch history walk — the recorded story \
293 is not reachable"
294 .to_string(),
295 );
296 Vec::new()
297 }
298 },
299 MountStorage::Folder { .. } | MountStorage::InMemory => {
300 limitations.push(
301 "changelog-backed history: rename records carry only the post-rename id \
302 (prior-id chains are not stitchable) and batch mutations record no \
303 per-entity attribution on this backend"
304 .to_string(),
305 );
306 let records = m
307 .backend
308 .read_provenance(None)
309 .map_err(EngineError::Backend)?;
310 filter_provenance_for_entity(entity_id, &records)
311 }
312 MountStorage::Archive { .. } => {
313 return Err(EngineError::InvalidInput(format!(
314 "mem '{mem}' is an archive mount — archives record no history at the \
315 engine seam; open the source mem for the entity's story"
316 )));
317 }
318 };
319
320 let story_start = match touches.last() {
325 Some(oldest) if oldest.verb.as_deref() == Some("create") => StoryStart::Recorded,
326 Some(oldest) => StoryStart::Truncated {
327 reason: format!(
328 "oldest recorded touch is a {} of `{}`, not the entity's creation — \
329 earlier history (an unrecorded prior id, or records predating the \
330 provenance log) is not reachable",
331 oldest.verb.as_deref().unwrap_or("touch"),
332 oldest.id_at_touch
333 ),
334 },
335 None => StoryStart::Truncated {
336 reason: "no touches recorded — the entity predates this mem's provenance \
337 record (an adopted or migrated mem)"
338 .to_string(),
339 },
340 };
341
342 let size = page_size
344 .unwrap_or(HISTORY_PAGE_DEFAULT)
345 .clamp(1, HISTORY_PAGE_MAX);
346 let start_idx = match cursor {
347 None => 0,
348 Some(c) => {
349 let pos = parse_cursor(c).and_then(|(reference, k)| {
350 touches
351 .iter()
352 .enumerate()
353 .filter(|(_, t)| t.reference == reference)
354 .nth(k)
355 .map(|(i, _)| i + 1)
356 });
357 pos.ok_or_else(|| EngineError::InvalidChangesCursor {
358 mem: mem.to_string(),
359 since: c.to_string(),
360 })?
361 }
362 };
363 let total_recorded = touches.len();
364 let page: Vec<EntityTouch> = touches[start_idx.min(total_recorded)..]
365 .iter()
366 .take(size)
367 .cloned()
368 .collect();
369 let next_cursor = if start_idx + page.len() < total_recorded {
370 page.last().map(|last| {
371 let k = touches[..start_idx + page.len()]
372 .iter()
373 .filter(|t| t.reference == last.reference)
374 .count()
375 - 1;
376 format!("{}@{k}", last.reference)
377 })
378 } else {
379 None
380 };
381
382 Ok(EntityHistoryReport {
383 mem: mem.to_string(),
384 entity_id: entity_id.to_string(),
385 touches: page,
386 total_recorded,
387 next_cursor,
388 story_start,
389 limitations,
390 })
391 }
392}
393
394fn parse_cursor(c: &str) -> Option<(String, usize)> {
399 let (reference, k) = c.rsplit_once('@')?;
400 if reference.is_empty() {
401 return None;
402 }
403 Some((reference.to_string(), k.parse().ok()?))
404}
405
406fn filter_notes_for_entity(
421 entity_id: &str,
422 notes: &[crate::ops::agent_notes::CommitNote],
423) -> Vec<EntityTouch> {
424 let mut current = entity_id.to_string();
425 let mut out: Vec<EntityTouch> = Vec::new();
426 for n in notes {
427 let base = |id_at: &str| EntityTouch {
428 reference: n.sha.clone(),
429 timestamp: n.timestamp,
430 id_at_touch: id_at.to_string(),
431 verb: n.tool_verb.clone(),
432 subject: Some(n.subject.clone()),
433 note: n.note.clone(),
434 actor: n.actor.clone(),
435 client: n.client.clone(),
436 tool: n.tool.clone(),
437 renamed_from: None,
438 renamed_to: None,
439 batch_entity_ids: Vec::new(),
440 logical_op: n.logical_operation_id.clone(),
441 role: n.role.clone(),
442 };
443 if n.tool_verb.as_deref() == Some("rename") {
444 if let Some((old, new)) = n.entity_id.as_deref().and_then(parse_rename_pair)
445 && new == current
446 {
447 let mut touch = base(&new);
448 touch.renamed_from = Some(old.clone());
449 touch.renamed_to = Some(new);
450 out.push(touch);
451 current = old;
452 }
453 continue;
454 }
455 if n.entity_id.as_deref() == Some(current.as_str()) {
456 let is_create = n.tool_verb.as_deref() == Some("create");
457 out.push(base(¤t));
458 if is_create {
459 break;
460 }
461 } else if n.entity_ids.iter().any(|id| id == ¤t) {
462 let mut touch = base(¤t);
463 touch.batch_entity_ids = n.entity_ids.clone();
464 out.push(touch);
465 }
466 }
467 out
468}
469
470fn filter_provenance_for_entity(
481 entity_id: &str,
482 records: &[crate::provenance::Provenance],
483) -> Vec<EntityTouch> {
484 let matching: Vec<&crate::provenance::Provenance> = records
485 .iter()
486 .filter(|p| p.entity.as_deref() == Some(entity_id))
487 .collect();
488 let birth = matching
489 .iter()
490 .rposition(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Create))
491 .unwrap_or(0);
492 let mut out: Vec<EntityTouch> = matching[birth..]
493 .iter()
494 .map(|p| {
495 let is_rename = matches!(p.kind, crate::provenance::ProvenanceKind::Rename);
496 EntityTouch {
497 reference: crate::filesystem::changelog::format_rfc3339_utc(p.timestamp),
498 timestamp: p
499 .timestamp
500 .duration_since(std::time::UNIX_EPOCH)
501 .map(|d| d.as_secs() as i64)
502 .unwrap_or(0),
503 id_at_touch: entity_id.to_string(),
504 verb: Some(p.kind.as_str().to_string()),
505 subject: None,
506 note: p.note.clone(),
507 actor: Some(p.actor.as_trailer().to_string()),
508 client: p
509 .client
510 .as_ref()
511 .map(|c| format!("{}@{}", c.name, c.version)),
512 tool: None,
513 renamed_from: None,
514 renamed_to: is_rename.then(|| entity_id.to_string()),
515 batch_entity_ids: Vec::new(),
516 logical_op: p.logical_operation_id.clone(),
517 role: p.role.as_trailer().map(str::to_string),
518 }
519 })
520 .collect();
521 out.reverse();
522 out
523}
524
525#[cfg(test)]
526mod tests {
527 use crate::storage::MemWriter;
528
529 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";
530
531 fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
535 let dir = tmp.path().join("specs");
536 if !dir.exists() {
537 std::fs::create_dir_all(&dir).unwrap();
538 let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
539 MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
540 .unwrap();
541 MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
542 }
543 let mount = crate::Mount {
544 mem: "specs".to_string(),
545 schema: Some(memstead_schema::SchemaRef::new(
546 "default",
547 semver::Version::new(1, 0, 0),
548 )),
549 storage: crate::MountStorage::Folder { path: dir.clone() },
550 capability: crate::MountCapability::Write,
551 lifecycle: crate::MountLifecycle::Eager,
552 cross_linkable: false,
553 migration_target: None,
554 };
555 let backend =
556 Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
557 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
558 }
559
560 fn create(engine: &mut crate::Engine, title: &str, note: &str) -> String {
561 let outcome = engine
562 .create_entity(
563 crate::CreateEntityArgs {
564 mem: "specs".to_string(),
565 title: title.to_string(),
566 entity_type: "spec".to_string(),
567 sections: [
568 ("identity".to_string(), "x".to_string()),
569 ("purpose".to_string(), "y".to_string()),
570 ]
571 .into_iter()
572 .collect(),
573 metadata: Default::default(),
574 relations: Vec::new(),
575 anchors: Vec::new(),
576 dry_run: false,
577 },
578 crate::vcs::Actor::Cli,
579 None,
580 Some(note),
581 )
582 .unwrap();
583 outcome.id.0
584 }
585
586 fn update(engine: &mut crate::Engine, id: &str, note: &str) {
587 engine
588 .update_entity(
589 crate::UpdateEntityArgs {
590 id: crate::EntityId(id.to_string()),
591 expected_hash: None,
592 sections: [("identity".to_string(), format!("touched: {note}"))]
593 .into_iter()
594 .collect(),
595 append_sections: Default::default(),
596 patch_sections: Default::default(),
597 metadata: Default::default(),
598 metadata_unset: Vec::new(),
599 dry_run: false,
600 declare_relations: Vec::new(),
601 anchors: Vec::new(),
602 relations_unset: Vec::new(),
603 anchors_unset: Vec::new(),
604 },
605 crate::vcs::Actor::App,
606 None,
607 Some(note),
608 )
609 .unwrap();
610 }
611
612 #[test]
613 fn folder_history_serves_touches_with_stated_limitations() {
614 let tmp = tempfile::TempDir::new().unwrap();
615 let mut engine = folder_engine(&tmp);
616 let id = create(&mut engine, "Story", "born");
617 update(&mut engine, &id, "grew");
618
619 let report = engine.entity_history("specs", &id, None, None).unwrap();
620 assert_eq!(report.total_recorded, 2);
621 assert_eq!(report.touches.len(), 2);
622 assert_eq!(report.touches[0].verb.as_deref(), Some("update"));
624 assert_eq!(report.touches[0].note.as_deref(), Some("grew"));
625 assert_eq!(report.touches[0].actor.as_deref(), Some("app"));
626 assert_eq!(report.touches[1].verb.as_deref(), Some("create"));
627 assert_eq!(report.touches[1].actor.as_deref(), Some("cli"));
628 assert_eq!(report.story_start, super::StoryStart::Recorded);
629 assert!(
630 report
631 .limitations
632 .iter()
633 .any(|l| l.contains("rename records carry only the post-rename id")),
634 "folder limitations must be stated: {:?}",
635 report.limitations
636 );
637 assert!(report.touches.iter().all(|t| t.id_at_touch == id));
639 }
640
641 #[test]
642 fn folder_rename_truncates_visibly_not_silently() {
643 let tmp = tempfile::TempDir::new().unwrap();
644 let mut engine = folder_engine(&tmp);
645 let id = create(&mut engine, "Before Rename", "born");
646 let outcome = engine
647 .rename_entity(
648 crate::RenameEntityArgs {
649 id: crate::EntityId(id.clone()),
650 expected_hash: None,
651 new_title: "After Rename".to_string(),
652 },
653 crate::vcs::Actor::Cli,
654 None,
655 Some("renamed"),
656 )
657 .unwrap();
658 let new_id = outcome.new_id.0;
659
660 let report = engine.entity_history("specs", &new_id, None, None).unwrap();
664 assert_eq!(report.touches[0].verb.as_deref(), Some("rename"));
665 assert!(report.touches[0].renamed_from.is_none());
666 match &report.story_start {
667 super::StoryStart::Truncated { reason } => {
668 assert!(
669 reason.contains("not the entity's creation"),
670 "reason must explain the truncation: {reason}"
671 );
672 }
673 other => panic!("expected visible truncation, got {other:?}"),
674 }
675 }
676
677 #[test]
678 fn reused_id_never_absorbs_the_previous_holders_story() {
679 let tmp = tempfile::TempDir::new().unwrap();
685 let mut engine = folder_engine(&tmp);
686 let id = create(&mut engine, "Slot", "first holder");
687 update(&mut engine, &id, "first holder grew");
688 engine
689 .rename_entity(
690 crate::RenameEntityArgs {
691 id: crate::EntityId(id.clone()),
692 expected_hash: None,
693 new_title: "Slot Moved".to_string(),
694 },
695 crate::vcs::Actor::Cli,
696 None,
697 Some("moved away"),
698 )
699 .unwrap();
700 let reused = create(&mut engine, "Slot", "second holder");
701 assert_eq!(reused, id, "the slug is reused");
702
703 let report = engine.entity_history("specs", &reused, None, None).unwrap();
704 assert_eq!(report.total_recorded, 1, "only its own birth: {report:#?}");
705 assert_eq!(report.touches[0].verb.as_deref(), Some("create"));
706 assert_eq!(report.touches[0].note.as_deref(), Some("second holder"));
707 assert!(matches!(report.story_start, super::StoryStart::Recorded));
708 }
709
710 #[test]
711 fn refusals_are_typed_never_empty_stories() {
712 let tmp = tempfile::TempDir::new().unwrap();
713 let mut engine = folder_engine(&tmp);
714 let id = create(&mut engine, "Real", "born");
715
716 let err = engine.entity_history("ghost", &id, None, None).unwrap_err();
718 assert_eq!(err.code(), "UNKNOWN_MEM");
719 let err = engine
721 .entity_history("specs", "specs--nope", None, None)
722 .unwrap_err();
723 assert_eq!(err.code(), "ENTITY_NOT_FOUND");
724 let err = engine
726 .entity_history("specs", &id, None, Some("zzz@0"))
727 .unwrap_err();
728 assert_eq!(err.code(), "INVALID_CURSOR");
729 let err = engine
730 .entity_history("specs", &id, None, Some("no-separator"))
731 .unwrap_err();
732 assert_eq!(err.code(), "INVALID_CURSOR");
733 }
734
735 #[test]
736 fn pre_changelog_entity_states_the_empty_record() {
737 let tmp = tempfile::TempDir::new().unwrap();
742 let engine = folder_engine(&tmp);
743 let report = engine
744 .entity_history("specs", "specs--seed", None, None)
745 .unwrap();
746 assert!(report.touches.is_empty());
747 assert!(matches!(
748 report.story_start,
749 super::StoryStart::Truncated { .. }
750 ));
751 }
752
753 #[test]
754 fn archive_mounts_refuse_rather_than_fabricate_emptiness() {
755 let dir = tempfile::TempDir::new().unwrap();
760 let backend = crate::storage::InMemoryBackend::new();
761 crate::backend::MemBackend::write_entity(
762 &backend,
763 std::path::Path::new("seed.md"),
764 SEED.as_bytes(),
765 )
766 .unwrap();
767 crate::backend::MemBackend::commit(
768 &backend,
769 "seed",
770 &crate::vcs::CommitContext::internal(),
771 )
772 .unwrap();
773 let mount = crate::Mount {
774 mem: "specs".to_string(),
775 schema: Some(memstead_schema::SchemaRef::new(
776 "default",
777 semver::Version::new(1, 0, 0),
778 )),
779 storage: crate::MountStorage::Archive {
780 path: dir.path().join("sealed.mem"),
781 },
782 capability: crate::MountCapability::Write,
783 lifecycle: crate::MountLifecycle::Eager,
784 cross_linkable: false,
785 migration_target: None,
786 };
787 let engine = crate::Engine::from_mounts(vec![(
788 mount,
789 Box::new(backend) as Box<dyn crate::MemBackend>,
790 )])
791 .unwrap();
792 let err = engine
793 .entity_history("specs", "specs--seed", None, None)
794 .unwrap_err();
795 assert_eq!(err.code(), "INVALID_INPUT");
796 }
797
798 #[test]
799 fn pages_compose_without_gaps_or_duplicates() {
800 let tmp = tempfile::TempDir::new().unwrap();
801 let mut engine = folder_engine(&tmp);
802 let id = create(&mut engine, "Paged", "born");
803 for i in 0..5 {
804 update(&mut engine, &id, &format!("touch {i}"));
805 }
806
807 let full = engine.entity_history("specs", &id, None, None).unwrap();
808 assert_eq!(full.total_recorded, 6);
809 assert!(full.next_cursor.is_none());
810
811 let mut collected: Vec<super::EntityTouch> = Vec::new();
813 let mut cursor: Option<String> = None;
814 loop {
815 let page = engine
816 .entity_history("specs", &id, Some(2), cursor.as_deref())
817 .unwrap();
818 assert!(page.touches.len() <= 2);
819 assert_eq!(page.total_recorded, 6, "every page states the whole");
820 collected.extend(page.touches.clone());
821 match page.next_cursor {
822 Some(c) => cursor = Some(c),
823 None => break,
824 }
825 }
826 assert_eq!(
827 collected, full.touches,
828 "paged walk must equal the single-page story exactly"
829 );
830 }
831}