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,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub last_check: Option<crate::check::CheckRecord>,
196 pub conformance_state: String,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub last_conformance_check: Option<crate::check::CheckRecord>,
204}
205
206fn touch_to_record(t: &EntityTouch) -> ProvenanceRecord {
207 ProvenanceRecord {
208 actor: t.actor.clone(),
209 client: t.client.clone(),
210 role: t.role.clone().unwrap_or_else(|| "unspecified".to_string()),
211 timestamp: t.timestamp,
212 reference: t.reference.clone(),
213 }
214}
215
216impl Engine {
217 pub fn entity_provenance(
226 &self,
227 mem: &str,
228 entity_id: &str,
229 ) -> Result<EntityProvenance, EngineError> {
230 let mut report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), None)?;
231 let newest = report.touches.first().cloned();
232 while let Some(cursor) = report.next_cursor.clone() {
234 report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), Some(&cursor))?;
235 }
236 let oldest = report.touches.last().or(newest.as_ref()).cloned();
237 let truncated = !matches!(report.story_start, StoryStart::Recorded);
238 let (check_state, last_check) = self.entity_check_state(mem, entity_id)?;
239 let (conformance_state, last_conformance_check) =
240 self.entity_conformance_state(mem, entity_id)?;
241 Ok(EntityProvenance {
242 created_by: match (&oldest, truncated) {
243 (Some(t), false) => Some(touch_to_record(t)),
244 _ => None,
245 },
246 last_modified_by: newest.as_ref().map(touch_to_record),
247 story_truncated: truncated,
248 check_state: check_state.as_str().to_string(),
249 last_check,
250 conformance_state: conformance_state.as_str().to_string(),
251 last_conformance_check,
252 })
253 }
254
255 pub fn entity_history(
266 &self,
267 mem: &str,
268 entity_id: &str,
269 page_size: Option<usize>,
270 cursor: Option<&str>,
271 ) -> Result<EntityHistoryReport, EngineError> {
272 let m = self.find_mount(mem)?;
273
274 let known = self
278 .store
279 .all_entities()
280 .any(|e| e.mem == mem && e.id.0 == entity_id);
281 if !known {
282 return Err(EngineError::NotFound {
283 id: entity_id.to_string(),
284 });
285 }
286
287 let mut limitations: Vec<String> = Vec::new();
288
289 let touches: Vec<EntityTouch> = match &m.mount.storage {
291 MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
292 Some(hook) => {
293 let backend_changes = (hook.changes_since)(
294 gitdir,
295 branch,
296 mem,
297 crate::ops::EMPTY_TREE_SHA,
298 crate::ops::RENAME_SIMILARITY_DEFAULT,
299 )
300 .map_err(EngineError::Backend)?;
301 filter_notes_for_entity(entity_id, &backend_changes.notes)
302 }
303 None => {
304 limitations.push(
305 "this build carries no git-branch history walk — the recorded story \
306 is not reachable"
307 .to_string(),
308 );
309 Vec::new()
310 }
311 },
312 MountStorage::Folder { .. } | MountStorage::InMemory => {
313 limitations.push(
314 "changelog-backed history: rename records carry only the post-rename id \
315 (prior-id chains are not stitchable) and batch mutations record no \
316 per-entity attribution on this backend"
317 .to_string(),
318 );
319 let records = m
320 .backend
321 .read_provenance(None)
322 .map_err(EngineError::Backend)?;
323 filter_provenance_for_entity(entity_id, &records)
324 }
325 MountStorage::Archive { .. } => {
326 return Err(EngineError::InvalidInput(format!(
327 "mem '{mem}' is an archive mount — archives record no history at the \
328 engine seam; open the source mem for the entity's story"
329 )));
330 }
331 };
332
333 let story_start = match touches.last() {
338 Some(oldest) if oldest.verb.as_deref() == Some("create") => StoryStart::Recorded,
339 Some(oldest) => StoryStart::Truncated {
340 reason: format!(
341 "oldest recorded touch is a {} of `{}`, not the entity's creation — \
342 earlier history (an unrecorded prior id, or records predating the \
343 provenance log) is not reachable",
344 oldest.verb.as_deref().unwrap_or("touch"),
345 oldest.id_at_touch
346 ),
347 },
348 None => StoryStart::Truncated {
349 reason: "no touches recorded — the entity predates this mem's provenance \
350 record (an adopted or migrated mem)"
351 .to_string(),
352 },
353 };
354
355 let size = page_size
357 .unwrap_or(HISTORY_PAGE_DEFAULT)
358 .clamp(1, HISTORY_PAGE_MAX);
359 let start_idx = match cursor {
360 None => 0,
361 Some(c) => {
362 let pos = parse_cursor(c).and_then(|(reference, k)| {
363 touches
364 .iter()
365 .enumerate()
366 .filter(|(_, t)| t.reference == reference)
367 .nth(k)
368 .map(|(i, _)| i + 1)
369 });
370 pos.ok_or_else(|| EngineError::InvalidChangesCursor {
371 mem: mem.to_string(),
372 since: c.to_string(),
373 })?
374 }
375 };
376 let total_recorded = touches.len();
377 let page: Vec<EntityTouch> = touches[start_idx.min(total_recorded)..]
378 .iter()
379 .take(size)
380 .cloned()
381 .collect();
382 let next_cursor = if start_idx + page.len() < total_recorded {
383 page.last().map(|last| {
384 let k = touches[..start_idx + page.len()]
385 .iter()
386 .filter(|t| t.reference == last.reference)
387 .count()
388 - 1;
389 format!("{}@{k}", last.reference)
390 })
391 } else {
392 None
393 };
394
395 Ok(EntityHistoryReport {
396 mem: mem.to_string(),
397 entity_id: entity_id.to_string(),
398 touches: page,
399 total_recorded,
400 next_cursor,
401 story_start,
402 limitations,
403 })
404 }
405}
406
407fn parse_cursor(c: &str) -> Option<(String, usize)> {
412 let (reference, k) = c.rsplit_once('@')?;
413 if reference.is_empty() {
414 return None;
415 }
416 Some((reference.to_string(), k.parse().ok()?))
417}
418
419fn filter_notes_for_entity(
434 entity_id: &str,
435 notes: &[crate::ops::agent_notes::CommitNote],
436) -> Vec<EntityTouch> {
437 let mut current = entity_id.to_string();
438 let mut out: Vec<EntityTouch> = Vec::new();
439 for n in notes {
440 let base = |id_at: &str| EntityTouch {
441 reference: n.sha.clone(),
442 timestamp: n.timestamp,
443 id_at_touch: id_at.to_string(),
444 verb: n.tool_verb.clone(),
445 subject: Some(n.subject.clone()),
446 note: n.note.clone(),
447 actor: n.actor.clone(),
448 client: n.client.clone(),
449 tool: n.tool.clone(),
450 renamed_from: None,
451 renamed_to: None,
452 batch_entity_ids: Vec::new(),
453 logical_op: n.logical_operation_id.clone(),
454 role: n.role.clone(),
455 };
456 if n.tool_verb.as_deref() == Some("rename") {
457 if let Some((old, new)) = n.entity_id.as_deref().and_then(parse_rename_pair)
458 && new == current
459 {
460 let mut touch = base(&new);
461 touch.renamed_from = Some(old.clone());
462 touch.renamed_to = Some(new);
463 out.push(touch);
464 current = old;
465 }
466 continue;
467 }
468 if n.entity_id.as_deref() == Some(current.as_str()) {
469 let is_create = n.tool_verb.as_deref() == Some("create");
470 out.push(base(¤t));
471 if is_create {
472 break;
473 }
474 } else if n.entity_ids.iter().any(|id| id == ¤t) {
475 let mut touch = base(¤t);
476 touch.batch_entity_ids = n.entity_ids.clone();
477 out.push(touch);
478 }
479 }
480 out
481}
482
483fn filter_provenance_for_entity(
494 entity_id: &str,
495 records: &[crate::provenance::Provenance],
496) -> Vec<EntityTouch> {
497 let matching: Vec<&crate::provenance::Provenance> = records
498 .iter()
499 .filter(|p| p.entity.as_deref() == Some(entity_id))
500 .collect();
501 let birth = matching
502 .iter()
503 .rposition(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Create))
504 .unwrap_or(0);
505 let mut out: Vec<EntityTouch> = matching[birth..]
506 .iter()
507 .map(|p| {
508 let is_rename = matches!(p.kind, crate::provenance::ProvenanceKind::Rename);
509 EntityTouch {
510 reference: crate::filesystem::changelog::format_rfc3339_utc(p.timestamp),
511 timestamp: p
512 .timestamp
513 .duration_since(std::time::UNIX_EPOCH)
514 .map(|d| d.as_secs() as i64)
515 .unwrap_or(0),
516 id_at_touch: entity_id.to_string(),
517 verb: Some(p.kind.as_str().to_string()),
518 subject: None,
519 note: p.note.clone(),
520 actor: Some(p.actor.as_trailer().to_string()),
521 client: p
522 .client
523 .as_ref()
524 .map(|c| format!("{}@{}", c.name, c.version)),
525 tool: None,
526 renamed_from: None,
527 renamed_to: is_rename.then(|| entity_id.to_string()),
528 batch_entity_ids: Vec::new(),
529 logical_op: p.logical_operation_id.clone(),
530 role: p.role.as_trailer().map(str::to_string),
531 }
532 })
533 .collect();
534 out.reverse();
535 out
536}
537
538#[cfg(test)]
539mod tests {
540 use crate::storage::MemWriter;
541
542 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";
543
544 fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
548 let dir = tmp.path().join("specs");
549 if !dir.exists() {
550 std::fs::create_dir_all(&dir).unwrap();
551 let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
552 MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
553 .unwrap();
554 MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
555 }
556 let mount = crate::Mount {
557 mem: "specs".to_string(),
558 schema: Some(memstead_schema::SchemaRef::new(
559 "default",
560 semver::Version::new(1, 0, 0),
561 )),
562 storage: crate::MountStorage::Folder { path: dir.clone() },
563 capability: crate::MountCapability::Write,
564 lifecycle: crate::MountLifecycle::Eager,
565 cross_linkable: false,
566 migration_target: None,
567 };
568 let backend =
569 Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
570 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
571 }
572
573 fn create(engine: &mut crate::Engine, title: &str, note: &str) -> String {
574 let outcome = engine
575 .create_entity(
576 crate::CreateEntityArgs {
577 mem: "specs".to_string(),
578 title: title.to_string(),
579 entity_type: "spec".to_string(),
580 sections: [
581 ("identity".to_string(), "x".to_string()),
582 ("purpose".to_string(), "y".to_string()),
583 ]
584 .into_iter()
585 .collect(),
586 metadata: Default::default(),
587 relations: Vec::new(),
588 anchors: Vec::new(),
589 dry_run: false,
590 },
591 crate::vcs::Actor::Cli,
592 None,
593 Some(note),
594 )
595 .unwrap();
596 outcome.id.0
597 }
598
599 fn update(engine: &mut crate::Engine, id: &str, note: &str) {
600 engine
601 .update_entity(
602 crate::UpdateEntityArgs {
603 id: crate::EntityId(id.to_string()),
604 expected_hash: None,
605 sections: [("identity".to_string(), format!("touched: {note}"))]
606 .into_iter()
607 .collect(),
608 append_sections: Default::default(),
609 patch_sections: Default::default(),
610 metadata: Default::default(),
611 metadata_unset: Vec::new(),
612 dry_run: false,
613 declare_relations: Vec::new(),
614 anchors: Vec::new(),
615 relations_unset: Vec::new(),
616 anchors_unset: Vec::new(),
617 },
618 crate::vcs::Actor::App,
619 None,
620 Some(note),
621 )
622 .unwrap();
623 }
624
625 #[test]
626 fn folder_history_serves_touches_with_stated_limitations() {
627 let tmp = tempfile::TempDir::new().unwrap();
628 let mut engine = folder_engine(&tmp);
629 let id = create(&mut engine, "Story", "born");
630 update(&mut engine, &id, "grew");
631
632 let report = engine.entity_history("specs", &id, None, None).unwrap();
633 assert_eq!(report.total_recorded, 2);
634 assert_eq!(report.touches.len(), 2);
635 assert_eq!(report.touches[0].verb.as_deref(), Some("update"));
637 assert_eq!(report.touches[0].note.as_deref(), Some("grew"));
638 assert_eq!(report.touches[0].actor.as_deref(), Some("app"));
639 assert_eq!(report.touches[1].verb.as_deref(), Some("create"));
640 assert_eq!(report.touches[1].actor.as_deref(), Some("cli"));
641 assert_eq!(report.story_start, super::StoryStart::Recorded);
642 assert!(
643 report
644 .limitations
645 .iter()
646 .any(|l| l.contains("rename records carry only the post-rename id")),
647 "folder limitations must be stated: {:?}",
648 report.limitations
649 );
650 assert!(report.touches.iter().all(|t| t.id_at_touch == id));
652 }
653
654 #[test]
655 fn folder_rename_truncates_visibly_not_silently() {
656 let tmp = tempfile::TempDir::new().unwrap();
657 let mut engine = folder_engine(&tmp);
658 let id = create(&mut engine, "Before Rename", "born");
659 let outcome = engine
660 .rename_entity(
661 crate::RenameEntityArgs {
662 id: crate::EntityId(id.clone()),
663 expected_hash: None,
664 new_title: "After Rename".to_string(),
665 },
666 crate::vcs::Actor::Cli,
667 None,
668 Some("renamed"),
669 )
670 .unwrap();
671 let new_id = outcome.new_id.0;
672
673 let report = engine.entity_history("specs", &new_id, None, None).unwrap();
677 assert_eq!(report.touches[0].verb.as_deref(), Some("rename"));
678 assert!(report.touches[0].renamed_from.is_none());
679 match &report.story_start {
680 super::StoryStart::Truncated { reason } => {
681 assert!(
682 reason.contains("not the entity's creation"),
683 "reason must explain the truncation: {reason}"
684 );
685 }
686 other => panic!("expected visible truncation, got {other:?}"),
687 }
688 }
689
690 #[test]
691 fn reused_id_never_absorbs_the_previous_holders_story() {
692 let tmp = tempfile::TempDir::new().unwrap();
698 let mut engine = folder_engine(&tmp);
699 let id = create(&mut engine, "Slot", "first holder");
700 update(&mut engine, &id, "first holder grew");
701 engine
702 .rename_entity(
703 crate::RenameEntityArgs {
704 id: crate::EntityId(id.clone()),
705 expected_hash: None,
706 new_title: "Slot Moved".to_string(),
707 },
708 crate::vcs::Actor::Cli,
709 None,
710 Some("moved away"),
711 )
712 .unwrap();
713 let reused = create(&mut engine, "Slot", "second holder");
714 assert_eq!(reused, id, "the slug is reused");
715
716 let report = engine.entity_history("specs", &reused, None, None).unwrap();
717 assert_eq!(report.total_recorded, 1, "only its own birth: {report:#?}");
718 assert_eq!(report.touches[0].verb.as_deref(), Some("create"));
719 assert_eq!(report.touches[0].note.as_deref(), Some("second holder"));
720 assert!(matches!(report.story_start, super::StoryStart::Recorded));
721 }
722
723 #[test]
724 fn refusals_are_typed_never_empty_stories() {
725 let tmp = tempfile::TempDir::new().unwrap();
726 let mut engine = folder_engine(&tmp);
727 let id = create(&mut engine, "Real", "born");
728
729 let err = engine.entity_history("ghost", &id, None, None).unwrap_err();
731 assert_eq!(err.code(), "UNKNOWN_MEM");
732 let err = engine
734 .entity_history("specs", "specs--nope", None, None)
735 .unwrap_err();
736 assert_eq!(err.code(), "ENTITY_NOT_FOUND");
737 let err = engine
739 .entity_history("specs", &id, None, Some("zzz@0"))
740 .unwrap_err();
741 assert_eq!(err.code(), "INVALID_CURSOR");
742 let err = engine
743 .entity_history("specs", &id, None, Some("no-separator"))
744 .unwrap_err();
745 assert_eq!(err.code(), "INVALID_CURSOR");
746 }
747
748 #[test]
749 fn pre_changelog_entity_states_the_empty_record() {
750 let tmp = tempfile::TempDir::new().unwrap();
755 let engine = folder_engine(&tmp);
756 let report = engine
757 .entity_history("specs", "specs--seed", None, None)
758 .unwrap();
759 assert!(report.touches.is_empty());
760 assert!(matches!(
761 report.story_start,
762 super::StoryStart::Truncated { .. }
763 ));
764 }
765
766 #[test]
767 fn archive_mounts_refuse_rather_than_fabricate_emptiness() {
768 let dir = tempfile::TempDir::new().unwrap();
773 let backend = crate::storage::InMemoryBackend::new();
774 crate::backend::MemBackend::write_entity(
775 &backend,
776 std::path::Path::new("seed.md"),
777 SEED.as_bytes(),
778 )
779 .unwrap();
780 crate::backend::MemBackend::commit(
781 &backend,
782 "seed",
783 &crate::vcs::CommitContext::internal(),
784 )
785 .unwrap();
786 let mount = crate::Mount {
787 mem: "specs".to_string(),
788 schema: Some(memstead_schema::SchemaRef::new(
789 "default",
790 semver::Version::new(1, 0, 0),
791 )),
792 storage: crate::MountStorage::Archive {
793 path: dir.path().join("sealed.mem"),
794 },
795 capability: crate::MountCapability::Write,
796 lifecycle: crate::MountLifecycle::Eager,
797 cross_linkable: false,
798 migration_target: None,
799 };
800 let engine = crate::Engine::from_mounts(vec![(
801 mount,
802 Box::new(backend) as Box<dyn crate::MemBackend>,
803 )])
804 .unwrap();
805 let err = engine
806 .entity_history("specs", "specs--seed", None, None)
807 .unwrap_err();
808 assert_eq!(err.code(), "INVALID_INPUT");
809 }
810
811 #[test]
812 fn pages_compose_without_gaps_or_duplicates() {
813 let tmp = tempfile::TempDir::new().unwrap();
814 let mut engine = folder_engine(&tmp);
815 let id = create(&mut engine, "Paged", "born");
816 for i in 0..5 {
817 update(&mut engine, &id, &format!("touch {i}"));
818 }
819
820 let full = engine.entity_history("specs", &id, None, None).unwrap();
821 assert_eq!(full.total_recorded, 6);
822 assert!(full.next_cursor.is_none());
823
824 let mut collected: Vec<super::EntityTouch> = Vec::new();
826 let mut cursor: Option<String> = None;
827 loop {
828 let page = engine
829 .entity_history("specs", &id, Some(2), cursor.as_deref())
830 .unwrap();
831 assert!(page.touches.len() <= 2);
832 assert_eq!(page.total_recorded, 6, "every page states the whole");
833 collected.extend(page.touches.clone());
834 match page.next_cursor {
835 Some(c) => cursor = Some(c),
836 None => break,
837 }
838 }
839 assert_eq!(
840 collected, full.touches,
841 "paged walk must equal the single-page story exactly"
842 );
843 }
844}