Skip to main content

memstead_base/engine/
history.rs

1//! Per-entity history — the narrative query behind an inspector's
2//! "how did this entity get this way".
3//!
4//! The engine already records everything a story needs (commit
5//! subjects, provenance trailers, agent notes, batch entity lists,
6//! rename chains) but exposed it only as branch-wide feeds; every
7//! consumer filtered client-side. This module owns the per-entity
8//! filter, rename-chain attribution, and pagination — one semantic for
9//! every surface.
10//!
11//! Data sources per backend, all pre-existing:
12//! - git-branch: the commit-note walk that already rides
13//!   `changes_since` (`BackendChanges::notes`, newest-first) — full
14//!   fidelity: sha, subject, trailers, batch `Entities:` lists,
15//!   authoritative rename pairs in the subject.
16//! - folder / in-memory: `MemBackend::read_provenance` (the
17//!   `.memstead/changes.jsonl` line scan / its in-memory analogue,
18//!   oldest-first) — same story, coarser: no batch entity lists, and
19//!   rename records carry only the post-rename id, so chains are not
20//!   stitchable. Both gaps surface as stated `limitations`, never as
21//!   silently absent entries.
22//! - archive: the seam records no history — typed refusal, never a
23//!   fabricated empty story.
24//!
25//! This query is read-only and mutates nothing; it deliberately stays
26//! narrative-only (content-level diffs per touch are the pairwise
27//! `diff` op's job, git-branch consumers only).
28
29use serde::Serialize;
30
31use super::{Engine, EngineError};
32use crate::workspace::MountStorage;
33
34/// Default page size when the caller passes `None`.
35pub const HISTORY_PAGE_DEFAULT: usize = 50;
36/// Hard page-size cap; larger requests clamp here.
37pub const HISTORY_PAGE_MAX: usize = 200;
38
39/// One recorded touch of an entity, newest-first in the report.
40#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
41pub struct EntityTouch {
42    /// Backend-native reference for this touch: the commit SHA on
43    /// git-branch mems, the changelog RFC-3339 timestamp on folder /
44    /// in-memory mems. Also the basis of the page cursor.
45    pub reference: String,
46    /// Touch time, seconds since unix epoch.
47    pub timestamp: i64,
48    /// The entity's id when this touch happened — pre-rename touches
49    /// carry their then-current id (criterion: the story starts at the
50    /// first appearance under any prior id).
51    pub id_at_touch: String,
52    /// Mutation verb (`create` / `update` / `delete` / `relate` /
53    /// `rename` / `batch_update`…). `None` when the record predates
54    /// the subject convention or the commit is external.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub verb: Option<String>,
57    /// Commit subject line (git-branch only).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub subject: Option<String>,
60    /// The agent's stated intent, where one was written.
61    #[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    /// `Tool:` trailer (git-branch only).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub tool: Option<String>,
70    /// On a rename touch: the id before the rename. `None` on
71    /// non-rename touches — and on folder-backend rename records,
72    /// which don't carry the pre-rename id (a stated limitation).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub renamed_from: Option<String>,
75    /// On a rename touch: the id after the rename.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub renamed_to: Option<String>,
78    /// Every id a multi-entity commit touched (batch context — names
79    /// the commit's scope without those entities' own stories
80    /// appearing here). Empty for single-entity touches.
81    #[serde(skip_serializing_if = "Vec::is_empty", default)]
82    pub batch_entity_ids: Vec<String>,
83    /// Correlation id linking commits of one logical operation.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub logical_op: Option<String>,
86    /// Caller-declared role this touch was performed in (agent-trust
87    /// plan 13). Absent = unspecified — recorded as absence, never
88    /// defaulted to a real role.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub role: Option<String>,
91}
92
93/// Where the returned story starts — the visible-truncation contract:
94/// whatever the record cannot reach is stated, never silent.
95#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
96#[serde(tag = "kind", rename_all = "lowercase")]
97pub enum StoryStart {
98    /// The oldest recorded touch is the entity's creation — the full
99    /// recorded story is reachable through the pages.
100    Recorded,
101    /// The story stops before the entity's first appearance, and here
102    /// is why (unstitchable rename, records predating the changelog,
103    /// an adopted mem with no history…).
104    Truncated { reason: String },
105}
106
107/// Result of [`Engine::entity_history`] — one page of the newest-first
108/// touch feed plus the explicit bounds of what it omits.
109#[derive(Debug, Clone, Serialize)]
110pub struct EntityHistoryReport {
111    pub mem: String,
112    /// The entity's current id (the query key).
113    pub entity_id: String,
114    /// This page's touches, newest first.
115    pub touches: Vec<EntityTouch>,
116    /// Total recorded touches across all pages — with `next_cursor`,
117    /// this states exactly what a page omits.
118    pub total_recorded: usize,
119    /// Opaque continuation: pass back as `cursor` for the next page.
120    /// `None` = this page ends the recorded story.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub next_cursor: Option<String>,
123    /// Where the recorded story starts (visible-truncation contract).
124    pub story_start: StoryStart,
125    /// Stated per-backend gaps (folder rename stitching, batch
126    /// attribution). Empty on full-fidelity backends.
127    #[serde(skip_serializing_if = "Vec::is_empty", default)]
128    pub limitations: Vec<String>,
129}
130
131/// Split a rename record's `entity_id` field (`"old → new"`) into the
132/// pair. Cross-mem peer rewrites (parenthetical qualifier) and
133/// malformed values return `None` — they modify wiki-link bodies in a
134/// peer mem, never the entity itself.
135fn 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/// One derived provenance record — who performed a boundary touch of
149/// an entity's story, in what declared role, when (agent-trust plan
150/// 13). Served by the entity read's opt-in provenance block.
151#[derive(Debug, Clone, serde::Serialize)]
152pub struct ProvenanceRecord {
153    /// Actor category (`agent` / `cli` / `app` / `external` /
154    /// `unknown`), from the recorded trailer.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub actor: Option<String>,
157    /// Client identity (`name@version`) when recorded.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub client: Option<String>,
160    /// The caller-declared role, or `"unspecified"` — absence is
161    /// served as the explicit cannot-confirm value, never as any
162    /// real role.
163    pub role: String,
164    /// Touch time, seconds since unix epoch.
165    pub timestamp: i64,
166    /// Backend-native reference (commit sha / ledger timestamp).
167    pub reference: String,
168}
169
170/// The entity read's derived provenance block: created-by and
171/// last-modified-by, derived from the append-only history record —
172/// never from anything an agent can edit after the fact.
173#[derive(Debug, Clone, serde::Serialize)]
174pub struct EntityProvenance {
175    /// The creation record. `None` when the recorded story does not
176    /// start at the entity's creation (adopted/migrated mems) — the
177    /// honesty pattern: absence stated, never fabricated.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub created_by: Option<ProvenanceRecord>,
180    /// The newest recorded touch.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub last_modified_by: Option<ProvenanceRecord>,
183    /// True when the recorded story is truncated (its oldest touch is
184    /// not the creation) — `created_by` is then absent.
185    #[serde(skip_serializing_if = "std::ops::Not::not")]
186    pub story_truncated: bool,
187    /// Derived check state (agent-trust plan 14): `never_checked` |
188    /// `checked_ok` | `check_failed` | `check_stale` — computed by
189    /// comparing the newest check record's entity-hash against the
190    /// current one, never stamped.
191    pub check_state: String,
192    /// The newest check record, when one exists.
193    #[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    /// Derive an entity's provenance block (agent-trust plan 13):
209    /// created-by (the oldest recorded touch, only when it IS the
210    /// creation) and last-modified-by (the newest touch), each with
211    /// actor identity, client, declared role, and timestamp — read
212    /// from the same append-only record `entity_history` serves, so
213    /// no verb can alter it after the fact. Pages through the full
214    /// story to reach the creation record when histories exceed one
215    /// page.
216    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        // Walk to the last page for the oldest touch.
224        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    /// An entity's recorded history: every touch, newest-first, with
243    /// rename chains followed so the story starts at the entity's
244    /// first appearance under any prior id. Bounded and pageable —
245    /// `page_size` clamps to [`HISTORY_PAGE_MAX`], `cursor` continues
246    /// a prior page (`INVALID_CURSOR` when it matches no touch).
247    ///
248    /// Refusals: `UNKNOWN_MEM`, `ENTITY_NOT_FOUND` (an unknown id
249    /// never yields an empty story), `INVALID_INPUT` on archive
250    /// mounts (their seam records no history — refusing beats
251    /// fabricating emptiness).
252    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        // An unknown entity refuses — marklessness-style honesty: the
262        // caller learns "no such entity", never an empty history that
263        // reads as "exists, untouched".
264        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        // ---- Collect the raw record, newest-first, per backend ----
277        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        // ---- Visible-truncation verdict ----
321        // The recorded story is complete exactly when its oldest touch
322        // is the entity's creation. Anything else — empty record,
323        // oldest touch mid-stream — states where and why it stops.
324        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        // ---- Page ----
343        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
394/// Cursor wire form: `<reference>@<occurrence>` where `occurrence`
395/// disambiguates touches sharing a reference (same-millisecond folder
396/// changelog lines; impossible for commit SHAs but the format stays
397/// uniform). Opaque to callers.
398fn 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
406/// Newest→oldest single pass over the commit-note walk, tracking the
407/// entity's id backwards through authoritative rename records:
408/// `current` starts at the queried id; a rename whose *new* id equals
409/// `current` is that entity's rename touch and flips `current` to the
410/// old id, so every older touch is matched under its then-current id.
411/// A rename whose *old* id equals `current` is a different entity's
412/// departure from an id later reused — deliberately not a touch.
413///
414/// The walk STOPS at the entity's creation: a `create` of the tracked
415/// id is the story's birth, and anything older under the same id
416/// belonged to a previous holder of a reused slug (renamed away or
417/// deleted before this entity existed) — absorbing it would attribute
418/// a stranger's touches to this entity and present the polluted story
419/// as `Recorded` (found by plan 03/02's grading gate).
420fn 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(&current));
458            if is_create {
459                break;
460            }
461        } else if n.entity_ids.iter().any(|id| id == &current) {
462            let mut touch = base(&current);
463            touch.batch_entity_ids = n.entity_ids.clone();
464            out.push(touch);
465        }
466    }
467    out
468}
469
470/// Changelog-backed record (folder / in-memory): filter the
471/// oldest-first provenance feed to the entity's id and reverse to
472/// newest-first. Rename records match only when they carry this
473/// entity's (post-rename) id — the pre-rename chain is not recorded
474/// on this backend (stated limitation upstream).
475///
476/// Reused-id guard, symmetric with the git-branch walk: the story
477/// starts at the id's NEWEST `create` record — older records under the
478/// same id belonged to a previous holder of the slug, never to this
479/// entity.
480fn 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    /// Folder-backed engine with one pre-existing entity written
532    /// outside the engine (no changelog record) — mirrors the review
533    /// module's fixture.
534    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        // Newest first: the update, then the create.
623        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        // Touches of the *other* entity (the seed) never appear.
638        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        // The folder changelog records renames under the post-rename
661        // id only — the story under the new id starts at the rename
662        // and SAYS so (never an unexplained short history).
663        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        // Rename an entity away, then create a NEW entity under the
680        // freed slug: the newcomer's story must start at ITS create —
681        // pre-fix it absorbed the previous holder's touches and
682        // presented the polluted story as `Recorded` (grading-gate
683        // finding, plan 03/02).
684        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        // Unknown mem.
717        let err = engine.entity_history("ghost", &id, None, None).unwrap_err();
718        assert_eq!(err.code(), "UNKNOWN_MEM");
719        // Unknown entity — never an empty history.
720        let err = engine
721            .entity_history("specs", "specs--nope", None, None)
722            .unwrap_err();
723        assert_eq!(err.code(), "ENTITY_NOT_FOUND");
724        // Garbage cursor.
725        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        // The seed entity was written outside the engine — no
738        // changelog line exists for it. Its story is empty AND says
739        // why, distinguishable from "exists, untouched" by the stated
740        // truncation.
741        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        // An archive-declared mount whose backend nonetheless serves
756        // the store: the storage arm must refuse before the seam's
757        // history-free `read_provenance` can masquerade as an empty
758        // story.
759        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        // Walk in pages of 2 and re-compose.
812        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}