use serde::Serialize;
use super::{Engine, EngineError};
use crate::workspace::MountStorage;
pub const HISTORY_PAGE_DEFAULT: usize = 50;
pub const HISTORY_PAGE_MAX: usize = 200;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct EntityTouch {
pub reference: String,
pub timestamp: i64,
pub id_at_touch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub verb: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub renamed_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub renamed_to: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub batch_entity_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logical_op: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum StoryStart {
Recorded,
Truncated { reason: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct EntityHistoryReport {
pub mem: String,
pub entity_id: String,
pub touches: Vec<EntityTouch>,
pub total_recorded: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub story_start: StoryStart,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub limitations: Vec<String>,
}
fn parse_rename_pair(field: &str) -> Option<(String, String)> {
if field.contains("(cross-mem rewrite") {
return None;
}
let mut parts = field.splitn(2, " → ");
let old = parts.next()?.trim();
let new = parts.next()?.trim();
if old.is_empty() || new.is_empty() {
return None;
}
Some((old.to_string(), new.to_string()))
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ProvenanceRecord {
#[serde(skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
pub role: String,
pub timestamp: i64,
pub reference: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct EntityProvenance {
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<ProvenanceRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_modified_by: Option<ProvenanceRecord>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub story_truncated: bool,
pub check_state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_check: Option<crate::check::CheckRecord>,
}
fn touch_to_record(t: &EntityTouch) -> ProvenanceRecord {
ProvenanceRecord {
actor: t.actor.clone(),
client: t.client.clone(),
role: t.role.clone().unwrap_or_else(|| "unspecified".to_string()),
timestamp: t.timestamp,
reference: t.reference.clone(),
}
}
impl Engine {
pub fn entity_provenance(
&self,
mem: &str,
entity_id: &str,
) -> Result<EntityProvenance, EngineError> {
let mut report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), None)?;
let newest = report.touches.first().cloned();
while let Some(cursor) = report.next_cursor.clone() {
report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), Some(&cursor))?;
}
let oldest = report.touches.last().or(newest.as_ref()).cloned();
let truncated = !matches!(report.story_start, StoryStart::Recorded);
let (check_state, last_check) = self.entity_check_state(mem, entity_id)?;
Ok(EntityProvenance {
created_by: match (&oldest, truncated) {
(Some(t), false) => Some(touch_to_record(t)),
_ => None,
},
last_modified_by: newest.as_ref().map(touch_to_record),
story_truncated: truncated,
check_state: check_state.as_str().to_string(),
last_check,
})
}
pub fn entity_history(
&self,
mem: &str,
entity_id: &str,
page_size: Option<usize>,
cursor: Option<&str>,
) -> Result<EntityHistoryReport, EngineError> {
let m = self.find_mount(mem)?;
let known = self
.store
.all_entities()
.any(|e| e.mem == mem && e.id.0 == entity_id);
if !known {
return Err(EngineError::NotFound {
id: entity_id.to_string(),
});
}
let mut limitations: Vec<String> = Vec::new();
let touches: Vec<EntityTouch> = match &m.mount.storage {
MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
Some(hook) => {
let backend_changes = (hook.changes_since)(
gitdir,
branch,
mem,
crate::ops::EMPTY_TREE_SHA,
crate::ops::RENAME_SIMILARITY_DEFAULT,
)
.map_err(EngineError::Backend)?;
filter_notes_for_entity(entity_id, &backend_changes.notes)
}
None => {
limitations.push(
"this build carries no git-branch history walk — the recorded story \
is not reachable"
.to_string(),
);
Vec::new()
}
},
MountStorage::Folder { .. } | MountStorage::InMemory => {
limitations.push(
"changelog-backed history: rename records carry only the post-rename id \
(prior-id chains are not stitchable) and batch mutations record no \
per-entity attribution on this backend"
.to_string(),
);
let records = m
.backend
.read_provenance(None)
.map_err(EngineError::Backend)?;
filter_provenance_for_entity(entity_id, &records)
}
MountStorage::Archive { .. } => {
return Err(EngineError::InvalidInput(format!(
"mem '{mem}' is an archive mount — archives record no history at the \
engine seam; open the source mem for the entity's story"
)));
}
};
let story_start = match touches.last() {
Some(oldest) if oldest.verb.as_deref() == Some("create") => StoryStart::Recorded,
Some(oldest) => StoryStart::Truncated {
reason: format!(
"oldest recorded touch is a {} of `{}`, not the entity's creation — \
earlier history (an unrecorded prior id, or records predating the \
provenance log) is not reachable",
oldest.verb.as_deref().unwrap_or("touch"),
oldest.id_at_touch
),
},
None => StoryStart::Truncated {
reason: "no touches recorded — the entity predates this mem's provenance \
record (an adopted or migrated mem)"
.to_string(),
},
};
let size = page_size
.unwrap_or(HISTORY_PAGE_DEFAULT)
.clamp(1, HISTORY_PAGE_MAX);
let start_idx = match cursor {
None => 0,
Some(c) => {
let pos = parse_cursor(c).and_then(|(reference, k)| {
touches
.iter()
.enumerate()
.filter(|(_, t)| t.reference == reference)
.nth(k)
.map(|(i, _)| i + 1)
});
pos.ok_or_else(|| EngineError::InvalidChangesCursor {
mem: mem.to_string(),
since: c.to_string(),
})?
}
};
let total_recorded = touches.len();
let page: Vec<EntityTouch> = touches[start_idx.min(total_recorded)..]
.iter()
.take(size)
.cloned()
.collect();
let next_cursor = if start_idx + page.len() < total_recorded {
page.last().map(|last| {
let k = touches[..start_idx + page.len()]
.iter()
.filter(|t| t.reference == last.reference)
.count()
- 1;
format!("{}@{k}", last.reference)
})
} else {
None
};
Ok(EntityHistoryReport {
mem: mem.to_string(),
entity_id: entity_id.to_string(),
touches: page,
total_recorded,
next_cursor,
story_start,
limitations,
})
}
}
fn parse_cursor(c: &str) -> Option<(String, usize)> {
let (reference, k) = c.rsplit_once('@')?;
if reference.is_empty() {
return None;
}
Some((reference.to_string(), k.parse().ok()?))
}
fn filter_notes_for_entity(
entity_id: &str,
notes: &[crate::ops::agent_notes::CommitNote],
) -> Vec<EntityTouch> {
let mut current = entity_id.to_string();
let mut out: Vec<EntityTouch> = Vec::new();
for n in notes {
let base = |id_at: &str| EntityTouch {
reference: n.sha.clone(),
timestamp: n.timestamp,
id_at_touch: id_at.to_string(),
verb: n.tool_verb.clone(),
subject: Some(n.subject.clone()),
note: n.note.clone(),
actor: n.actor.clone(),
client: n.client.clone(),
tool: n.tool.clone(),
renamed_from: None,
renamed_to: None,
batch_entity_ids: Vec::new(),
logical_op: n.logical_operation_id.clone(),
role: n.role.clone(),
};
if n.tool_verb.as_deref() == Some("rename") {
if let Some((old, new)) = n.entity_id.as_deref().and_then(parse_rename_pair)
&& new == current
{
let mut touch = base(&new);
touch.renamed_from = Some(old.clone());
touch.renamed_to = Some(new);
out.push(touch);
current = old;
}
continue;
}
if n.entity_id.as_deref() == Some(current.as_str()) {
let is_create = n.tool_verb.as_deref() == Some("create");
out.push(base(¤t));
if is_create {
break;
}
} else if n.entity_ids.iter().any(|id| id == ¤t) {
let mut touch = base(¤t);
touch.batch_entity_ids = n.entity_ids.clone();
out.push(touch);
}
}
out
}
fn filter_provenance_for_entity(
entity_id: &str,
records: &[crate::provenance::Provenance],
) -> Vec<EntityTouch> {
let matching: Vec<&crate::provenance::Provenance> = records
.iter()
.filter(|p| p.entity.as_deref() == Some(entity_id))
.collect();
let birth = matching
.iter()
.rposition(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Create))
.unwrap_or(0);
let mut out: Vec<EntityTouch> = matching[birth..]
.iter()
.map(|p| {
let is_rename = matches!(p.kind, crate::provenance::ProvenanceKind::Rename);
EntityTouch {
reference: crate::filesystem::changelog::format_rfc3339_utc(p.timestamp),
timestamp: p
.timestamp
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
id_at_touch: entity_id.to_string(),
verb: Some(p.kind.as_str().to_string()),
subject: None,
note: p.note.clone(),
actor: Some(p.actor.as_trailer().to_string()),
client: p
.client
.as_ref()
.map(|c| format!("{}@{}", c.name, c.version)),
tool: None,
renamed_from: None,
renamed_to: is_rename.then(|| entity_id.to_string()),
batch_entity_ids: Vec::new(),
logical_op: p.logical_operation_id.clone(),
role: p.role.as_trailer().map(str::to_string),
}
})
.collect();
out.reverse();
out
}
#[cfg(test)]
mod tests {
use crate::storage::MemWriter;
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";
fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
let dir = tmp.path().join("specs");
if !dir.exists() {
std::fs::create_dir_all(&dir).unwrap();
let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
.unwrap();
MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
}
let mount = crate::Mount {
mem: "specs".to_string(),
schema: Some(memstead_schema::SchemaRef::new(
"default",
semver::Version::new(1, 0, 0),
)),
storage: crate::MountStorage::Folder { path: dir.clone() },
capability: crate::MountCapability::Write,
lifecycle: crate::MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
};
let backend =
Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
}
fn create(engine: &mut crate::Engine, title: &str, note: &str) -> String {
let outcome = engine
.create_entity(
crate::CreateEntityArgs {
mem: "specs".to_string(),
title: title.to_string(),
entity_type: "spec".to_string(),
sections: [
("identity".to_string(), "x".to_string()),
("purpose".to_string(), "y".to_string()),
]
.into_iter()
.collect(),
metadata: Default::default(),
relations: Vec::new(),
anchors: Vec::new(),
dry_run: false,
},
crate::vcs::Actor::Cli,
None,
Some(note),
)
.unwrap();
outcome.id.0
}
fn update(engine: &mut crate::Engine, id: &str, note: &str) {
engine
.update_entity(
crate::UpdateEntityArgs {
id: crate::EntityId(id.to_string()),
expected_hash: None,
sections: [("identity".to_string(), format!("touched: {note}"))]
.into_iter()
.collect(),
append_sections: Default::default(),
patch_sections: Default::default(),
metadata: Default::default(),
metadata_unset: Vec::new(),
dry_run: false,
declare_relations: Vec::new(),
anchors: Vec::new(),
relations_unset: Vec::new(),
anchors_unset: Vec::new(),
},
crate::vcs::Actor::App,
None,
Some(note),
)
.unwrap();
}
#[test]
fn folder_history_serves_touches_with_stated_limitations() {
let tmp = tempfile::TempDir::new().unwrap();
let mut engine = folder_engine(&tmp);
let id = create(&mut engine, "Story", "born");
update(&mut engine, &id, "grew");
let report = engine.entity_history("specs", &id, None, None).unwrap();
assert_eq!(report.total_recorded, 2);
assert_eq!(report.touches.len(), 2);
assert_eq!(report.touches[0].verb.as_deref(), Some("update"));
assert_eq!(report.touches[0].note.as_deref(), Some("grew"));
assert_eq!(report.touches[0].actor.as_deref(), Some("app"));
assert_eq!(report.touches[1].verb.as_deref(), Some("create"));
assert_eq!(report.touches[1].actor.as_deref(), Some("cli"));
assert_eq!(report.story_start, super::StoryStart::Recorded);
assert!(
report
.limitations
.iter()
.any(|l| l.contains("rename records carry only the post-rename id")),
"folder limitations must be stated: {:?}",
report.limitations
);
assert!(report.touches.iter().all(|t| t.id_at_touch == id));
}
#[test]
fn folder_rename_truncates_visibly_not_silently() {
let tmp = tempfile::TempDir::new().unwrap();
let mut engine = folder_engine(&tmp);
let id = create(&mut engine, "Before Rename", "born");
let outcome = engine
.rename_entity(
crate::RenameEntityArgs {
id: crate::EntityId(id.clone()),
expected_hash: None,
new_title: "After Rename".to_string(),
},
crate::vcs::Actor::Cli,
None,
Some("renamed"),
)
.unwrap();
let new_id = outcome.new_id.0;
let report = engine.entity_history("specs", &new_id, None, None).unwrap();
assert_eq!(report.touches[0].verb.as_deref(), Some("rename"));
assert!(report.touches[0].renamed_from.is_none());
match &report.story_start {
super::StoryStart::Truncated { reason } => {
assert!(
reason.contains("not the entity's creation"),
"reason must explain the truncation: {reason}"
);
}
other => panic!("expected visible truncation, got {other:?}"),
}
}
#[test]
fn reused_id_never_absorbs_the_previous_holders_story() {
let tmp = tempfile::TempDir::new().unwrap();
let mut engine = folder_engine(&tmp);
let id = create(&mut engine, "Slot", "first holder");
update(&mut engine, &id, "first holder grew");
engine
.rename_entity(
crate::RenameEntityArgs {
id: crate::EntityId(id.clone()),
expected_hash: None,
new_title: "Slot Moved".to_string(),
},
crate::vcs::Actor::Cli,
None,
Some("moved away"),
)
.unwrap();
let reused = create(&mut engine, "Slot", "second holder");
assert_eq!(reused, id, "the slug is reused");
let report = engine.entity_history("specs", &reused, None, None).unwrap();
assert_eq!(report.total_recorded, 1, "only its own birth: {report:#?}");
assert_eq!(report.touches[0].verb.as_deref(), Some("create"));
assert_eq!(report.touches[0].note.as_deref(), Some("second holder"));
assert!(matches!(report.story_start, super::StoryStart::Recorded));
}
#[test]
fn refusals_are_typed_never_empty_stories() {
let tmp = tempfile::TempDir::new().unwrap();
let mut engine = folder_engine(&tmp);
let id = create(&mut engine, "Real", "born");
let err = engine.entity_history("ghost", &id, None, None).unwrap_err();
assert_eq!(err.code(), "UNKNOWN_MEM");
let err = engine
.entity_history("specs", "specs--nope", None, None)
.unwrap_err();
assert_eq!(err.code(), "ENTITY_NOT_FOUND");
let err = engine
.entity_history("specs", &id, None, Some("zzz@0"))
.unwrap_err();
assert_eq!(err.code(), "INVALID_CURSOR");
let err = engine
.entity_history("specs", &id, None, Some("no-separator"))
.unwrap_err();
assert_eq!(err.code(), "INVALID_CURSOR");
}
#[test]
fn pre_changelog_entity_states_the_empty_record() {
let tmp = tempfile::TempDir::new().unwrap();
let engine = folder_engine(&tmp);
let report = engine
.entity_history("specs", "specs--seed", None, None)
.unwrap();
assert!(report.touches.is_empty());
assert!(matches!(
report.story_start,
super::StoryStart::Truncated { .. }
));
}
#[test]
fn archive_mounts_refuse_rather_than_fabricate_emptiness() {
let dir = tempfile::TempDir::new().unwrap();
let backend = crate::storage::InMemoryBackend::new();
crate::backend::MemBackend::write_entity(
&backend,
std::path::Path::new("seed.md"),
SEED.as_bytes(),
)
.unwrap();
crate::backend::MemBackend::commit(
&backend,
"seed",
&crate::vcs::CommitContext::internal(),
)
.unwrap();
let mount = crate::Mount {
mem: "specs".to_string(),
schema: Some(memstead_schema::SchemaRef::new(
"default",
semver::Version::new(1, 0, 0),
)),
storage: crate::MountStorage::Archive {
path: dir.path().join("sealed.mem"),
},
capability: crate::MountCapability::Write,
lifecycle: crate::MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
};
let engine = crate::Engine::from_mounts(vec![(
mount,
Box::new(backend) as Box<dyn crate::MemBackend>,
)])
.unwrap();
let err = engine
.entity_history("specs", "specs--seed", None, None)
.unwrap_err();
assert_eq!(err.code(), "INVALID_INPUT");
}
#[test]
fn pages_compose_without_gaps_or_duplicates() {
let tmp = tempfile::TempDir::new().unwrap();
let mut engine = folder_engine(&tmp);
let id = create(&mut engine, "Paged", "born");
for i in 0..5 {
update(&mut engine, &id, &format!("touch {i}"));
}
let full = engine.entity_history("specs", &id, None, None).unwrap();
assert_eq!(full.total_recorded, 6);
assert!(full.next_cursor.is_none());
let mut collected: Vec<super::EntityTouch> = Vec::new();
let mut cursor: Option<String> = None;
loop {
let page = engine
.entity_history("specs", &id, Some(2), cursor.as_deref())
.unwrap();
assert!(page.touches.len() <= 2);
assert_eq!(page.total_recorded, 6, "every page states the whole");
collected.extend(page.touches.clone());
match page.next_cursor {
Some(c) => cursor = Some(c),
None => break,
}
}
assert_eq!(
collected, full.touches,
"paged walk must equal the single-page story exactly"
);
}
}