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    /// Caller-declared identity this touch was performed under
92    /// (agent-trust plan 15). Absent = undeclared — recorded as
93    /// absence, never inferred from actor or client.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub identity: Option<String>,
96}
97
98/// Where the returned story starts — the visible-truncation contract:
99/// whatever the record cannot reach is stated, never silent.
100#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
101#[serde(tag = "kind", rename_all = "lowercase")]
102pub enum StoryStart {
103    /// The oldest recorded touch is the entity's creation — the full
104    /// recorded story is reachable through the pages.
105    Recorded,
106    /// The story stops before the entity's first appearance, and here
107    /// is why (unstitchable rename, records predating the changelog,
108    /// an adopted mem with no history…).
109    Truncated { reason: String },
110}
111
112/// Result of [`Engine::entity_history`] — one page of the newest-first
113/// touch feed plus the explicit bounds of what it omits.
114#[derive(Debug, Clone, Serialize)]
115pub struct EntityHistoryReport {
116    pub mem: String,
117    /// The entity's current id (the query key).
118    pub entity_id: String,
119    /// This page's touches, newest first.
120    pub touches: Vec<EntityTouch>,
121    /// Total recorded touches across all pages — with `next_cursor`,
122    /// this states exactly what a page omits.
123    pub total_recorded: usize,
124    /// Opaque continuation: pass back as `cursor` for the next page.
125    /// `None` = this page ends the recorded story.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub next_cursor: Option<String>,
128    /// Where the recorded story starts (visible-truncation contract).
129    pub story_start: StoryStart,
130    /// Stated per-backend gaps (folder rename stitching, batch
131    /// attribution). Empty on full-fidelity backends.
132    #[serde(skip_serializing_if = "Vec::is_empty", default)]
133    pub limitations: Vec<String>,
134}
135
136/// Split a rename record's `entity_id` field (`"old → new"`) into the
137/// pair. Cross-mem peer rewrites (parenthetical qualifier) and
138/// malformed values return `None` — they modify wiki-link bodies in a
139/// peer mem, never the entity itself.
140fn parse_rename_pair(field: &str) -> Option<(String, String)> {
141    if field.contains("(cross-mem rewrite") {
142        return None;
143    }
144    let mut parts = field.splitn(2, " → ");
145    let old = parts.next()?.trim();
146    let new = parts.next()?.trim();
147    if old.is_empty() || new.is_empty() {
148        return None;
149    }
150    Some((old.to_string(), new.to_string()))
151}
152
153/// One derived provenance record — who performed a boundary touch of
154/// an entity's story, in what declared role, when (agent-trust plan
155/// 13). Served by the entity read's opt-in provenance block.
156#[derive(Debug, Clone, serde::Serialize)]
157pub struct ProvenanceRecord {
158    /// Actor category (`agent` / `cli` / `app` / `external` /
159    /// `unknown`), from the recorded trailer.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub actor: Option<String>,
162    /// Client identity (`name@version`) when recorded.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub client: Option<String>,
165    /// The caller-declared role, or `"unspecified"` — absence is
166    /// served as the explicit cannot-confirm value, never as any
167    /// real role.
168    pub role: String,
169    /// The caller-declared identity, when one was recorded — the
170    /// independence gate's only comparator (agent-trust plan 15).
171    /// Absence downgrades comparisons to `unconfirmable`.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub identity: Option<String>,
174    /// Touch time, seconds since unix epoch.
175    pub timestamp: i64,
176    /// Backend-native reference (commit sha / ledger timestamp).
177    pub reference: String,
178    /// The mutation verb of the touch (`create` / `update` / `rename` /
179    /// `retype` / `relate` / …), when the record carries one — so a
180    /// reader can tell a type change from an edit without opening the
181    /// commit.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub verb: Option<String>,
184}
185
186/// The entity read's derived provenance block: created-by and
187/// last-modified-by, derived from the append-only history record —
188/// never from anything an agent can edit after the fact.
189#[derive(Debug, Clone, serde::Serialize)]
190pub struct EntityProvenance {
191    /// The creation record. `None` when the recorded story does not
192    /// start at the entity's creation (adopted/migrated mems) — the
193    /// honesty pattern: absence stated, never fabricated.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub created_by: Option<ProvenanceRecord>,
196    /// The newest recorded touch.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub last_modified_by: Option<ProvenanceRecord>,
199    /// True when the recorded story is truncated (its oldest touch is
200    /// not the creation) — `created_by` is then absent.
201    #[serde(skip_serializing_if = "std::ops::Not::not")]
202    pub story_truncated: bool,
203    /// Derived `verification` check state (agent-trust plan 14):
204    /// `never_checked` | `checked_ok` | `check_failed` | `check_stale`
205    /// — computed by comparing the newest verification record's
206    /// entity-hash against the current one, never stamped. Kind-scoped:
207    /// a conformance record never supersedes it.
208    pub check_state: String,
209    /// The newest `verification` record, when one exists.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub last_check: Option<crate::check::CheckRecord>,
212    /// Derived `conformance` state — the same four values, computed
213    /// against the newest conformance record with pin-awareness (a
214    /// schema re-pin stales it). `never_checked` when no conformance
215    /// record exists, which is every pre-kind workspace.
216    pub conformance_state: String,
217    /// The newest `conformance` record, when one exists.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub last_conformance_check: Option<crate::check::CheckRecord>,
220}
221
222fn touch_to_record(t: &EntityTouch) -> ProvenanceRecord {
223    ProvenanceRecord {
224        actor: t.actor.clone(),
225        client: t.client.clone(),
226        role: t.role.clone().unwrap_or_else(|| "unspecified".to_string()),
227        identity: t.identity.clone(),
228        timestamp: t.timestamp,
229        reference: t.reference.clone(),
230        verb: t.verb.clone(),
231    }
232}
233
234impl Engine {
235    /// Derive an entity's provenance block (agent-trust plan 13):
236    /// created-by (the oldest recorded touch, only when it IS the
237    /// creation) and last-modified-by (the newest touch), each with
238    /// actor identity, client, declared role, and timestamp — read
239    /// from the same append-only record `entity_history` serves, so
240    /// no verb can alter it after the fact. Pages through the full
241    /// story to reach the creation record when histories exceed one
242    /// page.
243    pub fn entity_provenance(
244        &self,
245        mem: &str,
246        entity_id: &str,
247    ) -> Result<EntityProvenance, EngineError> {
248        let mut report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), None)?;
249        let newest = report.touches.first().cloned();
250        // Walk to the last page for the oldest touch.
251        while let Some(cursor) = report.next_cursor.clone() {
252            report = self.entity_history(mem, entity_id, Some(HISTORY_PAGE_MAX), Some(&cursor))?;
253        }
254        let oldest = report.touches.last().or(newest.as_ref()).cloned();
255        let truncated = !matches!(report.story_start, StoryStart::Recorded);
256        let (check_state, last_check) = self.entity_check_state(mem, entity_id)?;
257        let (conformance_state, last_conformance_check) =
258            self.entity_conformance_state(mem, entity_id)?;
259        Ok(EntityProvenance {
260            created_by: match (&oldest, truncated) {
261                (Some(t), false) => Some(touch_to_record(t)),
262                _ => None,
263            },
264            last_modified_by: newest.as_ref().map(touch_to_record),
265            story_truncated: truncated,
266            check_state: check_state.as_str().to_string(),
267            last_check,
268            conformance_state: conformance_state.as_str().to_string(),
269            last_conformance_check,
270        })
271    }
272
273    /// An entity's recorded history: every touch, newest-first, with
274    /// rename chains followed so the story starts at the entity's
275    /// first appearance under any prior id. Bounded and pageable —
276    /// `page_size` clamps to [`HISTORY_PAGE_MAX`], `cursor` continues
277    /// a prior page (`INVALID_CURSOR` when it matches no touch).
278    ///
279    /// Refusals: `UNKNOWN_MEM`, `ENTITY_NOT_FOUND` (an unknown id
280    /// never yields an empty story), `INVALID_INPUT` on archive
281    /// mounts (their seam records no history — refusing beats
282    /// fabricating emptiness).
283    pub fn entity_history(
284        &self,
285        mem: &str,
286        entity_id: &str,
287        page_size: Option<usize>,
288        cursor: Option<&str>,
289    ) -> Result<EntityHistoryReport, EngineError> {
290        let m = self.find_mount(mem)?;
291
292        // An unknown entity refuses — marklessness-style honesty: the
293        // caller learns "no such entity", never an empty history that
294        // reads as "exists, untouched".
295        let known = self
296            .store
297            .all_entities()
298            .any(|e| e.mem == mem && e.id.0 == entity_id);
299        if !known {
300            return Err(EngineError::NotFound {
301                id: entity_id.to_string(),
302            });
303        }
304
305        let mut limitations: Vec<String> = Vec::new();
306
307        // ---- Collect the raw record, newest-first, per backend ----
308        let touches: Vec<EntityTouch> = match &m.mount.storage {
309            MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
310                Some(hook) => {
311                    let backend_changes = (hook.changes_since)(
312                        gitdir,
313                        branch,
314                        mem,
315                        crate::ops::EMPTY_TREE_SHA,
316                        crate::ops::RENAME_SIMILARITY_DEFAULT,
317                    )
318                    .map_err(EngineError::Backend)?;
319                    filter_notes_for_entity(entity_id, &backend_changes.notes)
320                }
321                None => {
322                    limitations.push(
323                        "this build carries no git-branch history walk — the recorded story \
324                         is not reachable"
325                            .to_string(),
326                    );
327                    Vec::new()
328                }
329            },
330            MountStorage::Folder { .. } | MountStorage::InMemory => {
331                limitations.push(
332                    "changelog-backed history: rename records carry only the post-rename id \
333                     (prior-id chains are not stitchable) and batch mutations record no \
334                     per-entity attribution on this backend"
335                        .to_string(),
336                );
337                let records = m
338                    .backend
339                    .read_provenance(None)
340                    .map_err(EngineError::Backend)?;
341                filter_provenance_for_entity(entity_id, &records)
342            }
343            MountStorage::Archive { .. } => {
344                return Err(EngineError::InvalidInput(format!(
345                    "mem '{mem}' is an archive mount — archives record no history at the \
346                     engine seam; open the source mem for the entity's story"
347                )));
348            }
349        };
350
351        // ---- Visible-truncation verdict ----
352        // The recorded story is complete exactly when its oldest touch
353        // is the entity's creation. Anything else — empty record,
354        // oldest touch mid-stream — states where and why it stops.
355        let story_start = match touches.last() {
356            // `batch-create` IS this entity's creation when it is the
357            // oldest touch — a batch-authored entity carries the same
358            // append-only provenance (role, identity trailers) as a
359            // single create, and treating it as truncation erased
360            // `created_by` for every batch-authored entity, which
361            // silently degraded the checks independence gate to
362            // `unconfirmable` (found 2026-08-28, graph-plans pilot).
363            Some(oldest)
364                if matches!(
365                    oldest.verb.as_deref(),
366                    Some("create") | Some("batch-create")
367                ) =>
368            {
369                StoryStart::Recorded
370            }
371            Some(oldest) => StoryStart::Truncated {
372                reason: format!(
373                    "oldest recorded touch is a {} of `{}`, not the entity's creation — \
374                     earlier history (an unrecorded prior id, or records predating the \
375                     provenance log) is not reachable",
376                    oldest.verb.as_deref().unwrap_or("touch"),
377                    oldest.id_at_touch
378                ),
379            },
380            None => StoryStart::Truncated {
381                reason: "no touches recorded — the entity predates this mem's provenance \
382                         record (an adopted or migrated mem)"
383                    .to_string(),
384            },
385        };
386
387        // ---- Page ----
388        let size = page_size
389            .unwrap_or(HISTORY_PAGE_DEFAULT)
390            .clamp(1, HISTORY_PAGE_MAX);
391        let start_idx = match cursor {
392            None => 0,
393            Some(c) => {
394                let pos = parse_cursor(c).and_then(|(reference, k)| {
395                    touches
396                        .iter()
397                        .enumerate()
398                        .filter(|(_, t)| t.reference == reference)
399                        .nth(k)
400                        .map(|(i, _)| i + 1)
401                });
402                pos.ok_or_else(|| EngineError::InvalidChangesCursor {
403                    mem: mem.to_string(),
404                    since: c.to_string(),
405                })?
406            }
407        };
408        let total_recorded = touches.len();
409        let page: Vec<EntityTouch> = touches[start_idx.min(total_recorded)..]
410            .iter()
411            .take(size)
412            .cloned()
413            .collect();
414        let next_cursor = if start_idx + page.len() < total_recorded {
415            page.last().map(|last| {
416                let k = touches[..start_idx + page.len()]
417                    .iter()
418                    .filter(|t| t.reference == last.reference)
419                    .count()
420                    - 1;
421                format!("{}@{k}", last.reference)
422            })
423        } else {
424            None
425        };
426
427        Ok(EntityHistoryReport {
428            mem: mem.to_string(),
429            entity_id: entity_id.to_string(),
430            touches: page,
431            total_recorded,
432            next_cursor,
433            story_start,
434            limitations,
435        })
436    }
437}
438
439/// Cursor wire form: `<reference>@<occurrence>` where `occurrence`
440/// disambiguates touches sharing a reference (same-millisecond folder
441/// changelog lines; impossible for commit SHAs but the format stays
442/// uniform). Opaque to callers.
443fn parse_cursor(c: &str) -> Option<(String, usize)> {
444    let (reference, k) = c.rsplit_once('@')?;
445    if reference.is_empty() {
446        return None;
447    }
448    Some((reference.to_string(), k.parse().ok()?))
449}
450
451/// Newest→oldest single pass over the commit-note walk, tracking the
452/// entity's id backwards through authoritative rename records:
453/// `current` starts at the queried id; a rename whose *new* id equals
454/// `current` is that entity's rename touch and flips `current` to the
455/// old id, so every older touch is matched under its then-current id.
456/// A rename whose *old* id equals `current` is a different entity's
457/// departure from an id later reused — deliberately not a touch.
458///
459/// The walk STOPS at the entity's creation: a `create` of the tracked
460/// id is the story's birth, and anything older under the same id
461/// belonged to a previous holder of a reused slug (renamed away or
462/// deleted before this entity existed) — absorbing it would attribute
463/// a stranger's touches to this entity and present the polluted story
464/// as `Recorded` (found by plan 03/02's grading gate).
465fn filter_notes_for_entity(
466    entity_id: &str,
467    notes: &[crate::ops::agent_notes::CommitNote],
468) -> Vec<EntityTouch> {
469    let mut current = entity_id.to_string();
470    let mut out: Vec<EntityTouch> = Vec::new();
471    for n in notes {
472        let base = |id_at: &str| EntityTouch {
473            reference: n.sha.clone(),
474            timestamp: n.timestamp,
475            id_at_touch: id_at.to_string(),
476            verb: n.tool_verb.clone(),
477            subject: Some(n.subject.clone()),
478            note: n.note.clone(),
479            actor: n.actor.clone(),
480            client: n.client.clone(),
481            tool: n.tool.clone(),
482            renamed_from: None,
483            renamed_to: None,
484            batch_entity_ids: Vec::new(),
485            logical_op: n.logical_operation_id.clone(),
486            role: n.role.clone(),
487            identity: n.identity.clone(),
488        };
489        if n.tool_verb.as_deref() == Some("rename") {
490            if let Some((old, new)) = n.entity_id.as_deref().and_then(parse_rename_pair)
491                && new == current
492            {
493                let mut touch = base(&new);
494                touch.renamed_from = Some(old.clone());
495                touch.renamed_to = Some(new);
496                out.push(touch);
497                current = old;
498            }
499            continue;
500        }
501        if n.entity_id.as_deref() == Some(current.as_str()) {
502            let is_create = n.tool_verb.as_deref() == Some("create");
503            out.push(base(&current));
504            if is_create {
505                break;
506            }
507        } else if n.entity_ids.iter().any(|id| id == &current) {
508            let mut touch = base(&current);
509            touch.batch_entity_ids = n.entity_ids.clone();
510            out.push(touch);
511            // A batch-create that lists this entity is its creation —
512            // nothing older can touch it, same stop as single create.
513            if n.tool_verb.as_deref() == Some("batch-create") {
514                break;
515            }
516        }
517    }
518    out
519}
520
521/// Changelog-backed record (folder / in-memory): filter the
522/// oldest-first provenance feed to the entity's id and reverse to
523/// newest-first. Rename records match only when they carry this
524/// entity's (post-rename) id — the pre-rename chain is not recorded
525/// on this backend (stated limitation upstream).
526///
527/// Reused-id guard, symmetric with the git-branch walk: the story
528/// starts at the id's NEWEST `create` record — older records under the
529/// same id belonged to a previous holder of the slug, never to this
530/// entity.
531fn filter_provenance_for_entity(
532    entity_id: &str,
533    records: &[crate::provenance::Provenance],
534) -> Vec<EntityTouch> {
535    let matching: Vec<&crate::provenance::Provenance> = records
536        .iter()
537        .filter(|p| p.entity.as_deref() == Some(entity_id))
538        .collect();
539    let birth = matching
540        .iter()
541        .rposition(|p| matches!(p.kind, crate::provenance::ProvenanceKind::Create))
542        .unwrap_or(0);
543    let mut out: Vec<EntityTouch> = matching[birth..]
544        .iter()
545        .map(|p| {
546            let is_rename = matches!(p.kind, crate::provenance::ProvenanceKind::Rename);
547            EntityTouch {
548                reference: crate::filesystem::changelog::format_rfc3339_utc(p.timestamp),
549                timestamp: p
550                    .timestamp
551                    .duration_since(std::time::UNIX_EPOCH)
552                    .map(|d| d.as_secs() as i64)
553                    .unwrap_or(0),
554                id_at_touch: entity_id.to_string(),
555                verb: Some(p.kind.as_str().to_string()),
556                subject: None,
557                note: p.note.clone(),
558                actor: Some(p.actor.as_trailer().to_string()),
559                client: p
560                    .client
561                    .as_ref()
562                    .map(|c| format!("{}@{}", c.name, c.version)),
563                tool: None,
564                renamed_from: None,
565                renamed_to: is_rename.then(|| entity_id.to_string()),
566                batch_entity_ids: Vec::new(),
567                logical_op: p.logical_operation_id.clone(),
568                role: p.role.as_trailer().map(str::to_string),
569                identity: p.identity.clone(),
570            }
571        })
572        .collect();
573    out.reverse();
574    out
575}
576
577#[cfg(test)]
578mod tests {
579    use crate::storage::MemWriter;
580
581    /// A `batch-create` commit that lists the entity IS its creation:
582    /// the filter stops walking older history there (regression: an
583    /// older unrelated note must not attach), and the touch keeps the
584    /// `batch-create` verb the story-start check accepts — before the
585    /// 2026-08-28 fix every batch-authored entity read as truncated,
586    /// which erased `created_by` and degraded the checks independence
587    /// gate to `unconfirmable`.
588    #[test]
589    fn batch_create_note_is_the_entitys_creation() {
590        use crate::ops::agent_notes::CommitNote;
591        let note = |sha: &str, verb: &str, entity_id: Option<&str>, ids: Vec<&str>| CommitNote {
592            mem: "m".into(),
593            sha: sha.into(),
594            subject: format!("memstead: {verb}"),
595            tool_verb: Some(verb.into()),
596            entity_id: entity_id.map(str::to_string),
597            note: None,
598            actor: Some("cli".into()),
599            tool: None,
600            client: None,
601            logical_operation_id: None,
602            role: Some("author".into()),
603            identity: Some("author-x".into()),
604            entity_ids: ids.into_iter().map(str::to_string).collect(),
605            timestamp: 1,
606        };
607        // Newest-first: an update, then the batch create, then an
608        // unrelated older note that must never be reached.
609        let notes = vec![
610            note("c3", "update", Some("m--thing"), vec![]),
611            note("c2", "batch-create", None, vec!["m--other", "m--thing"]),
612            note("c1", "update", Some("m--thing"), vec![]),
613        ];
614        let touches = super::filter_notes_for_entity("m--thing", &notes);
615        assert_eq!(
616            touches.len(),
617            2,
618            "the walk stops at the batch create: {touches:?}"
619        );
620        let oldest = touches.last().unwrap();
621        assert_eq!(oldest.verb.as_deref(), Some("batch-create"));
622        assert_eq!(oldest.identity.as_deref(), Some("author-x"));
623        assert!(
624            matches!(
625                oldest.verb.as_deref(),
626                Some("create") | Some("batch-create")
627            ),
628            "the story-start acceptance covers the batch verb"
629        );
630    }
631
632    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";
633
634    /// Folder-backed engine with one pre-existing entity written
635    /// outside the engine (no changelog record) — mirrors the review
636    /// module's fixture.
637    fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
638        let dir = tmp.path().join("specs");
639        if !dir.exists() {
640            std::fs::create_dir_all(&dir).unwrap();
641            let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
642            MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
643                .unwrap();
644            MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
645        }
646        let mount = crate::Mount {
647            mem: "specs".to_string(),
648            schema: Some(memstead_schema::SchemaRef::new(
649                "default",
650                semver::Version::new(1, 0, 0),
651            )),
652            storage: crate::MountStorage::Folder { path: dir.clone() },
653            capability: crate::MountCapability::Write,
654            lifecycle: crate::MountLifecycle::Eager,
655            cross_linkable: false,
656            migration_target: None,
657        };
658        let backend =
659            Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
660        crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
661    }
662
663    fn create(engine: &mut crate::Engine, title: &str, note: &str) -> String {
664        let outcome = engine
665            .create_entity(
666                crate::CreateEntityArgs {
667                    mem: "specs".to_string(),
668                    title: title.to_string(),
669                    entity_type: "spec".to_string(),
670                    sections: [
671                        ("identity".to_string(), "x".to_string()),
672                        ("purpose".to_string(), "y".to_string()),
673                    ]
674                    .into_iter()
675                    .collect(),
676                    metadata: Default::default(),
677                    relations: Vec::new(),
678                    anchors: Vec::new(),
679                    dry_run: false,
680                },
681                crate::vcs::Actor::Cli,
682                None,
683                Some(note),
684            )
685            .unwrap();
686        outcome.id.0
687    }
688
689    fn update(engine: &mut crate::Engine, id: &str, note: &str) {
690        engine
691            .update_entity(
692                crate::UpdateEntityArgs {
693                    id: crate::EntityId(id.to_string()),
694                    expected_hash: None,
695                    sections: [("identity".to_string(), format!("touched: {note}"))]
696                        .into_iter()
697                        .collect(),
698                    append_sections: Default::default(),
699                    patch_sections: Default::default(),
700                    sections_unset: Vec::new(),
701                    metadata: Default::default(),
702                    metadata_unset: Vec::new(),
703                    dry_run: false,
704                    declare_relations: Vec::new(),
705                    anchors: Vec::new(),
706                    relations_unset: Vec::new(),
707                    anchors_unset: Vec::new(),
708                },
709                crate::vcs::Actor::App,
710                None,
711                Some(note),
712            )
713            .unwrap();
714    }
715
716    #[test]
717    fn folder_history_serves_touches_with_stated_limitations() {
718        let tmp = tempfile::TempDir::new().unwrap();
719        let mut engine = folder_engine(&tmp);
720        let id = create(&mut engine, "Story", "born");
721        update(&mut engine, &id, "grew");
722
723        let report = engine.entity_history("specs", &id, None, None).unwrap();
724        assert_eq!(report.total_recorded, 2);
725        assert_eq!(report.touches.len(), 2);
726        // Newest first: the update, then the create.
727        assert_eq!(report.touches[0].verb.as_deref(), Some("update"));
728        assert_eq!(report.touches[0].note.as_deref(), Some("grew"));
729        assert_eq!(report.touches[0].actor.as_deref(), Some("app"));
730        assert_eq!(report.touches[1].verb.as_deref(), Some("create"));
731        assert_eq!(report.touches[1].actor.as_deref(), Some("cli"));
732        assert_eq!(report.story_start, super::StoryStart::Recorded);
733        assert!(
734            report
735                .limitations
736                .iter()
737                .any(|l| l.contains("rename records carry only the post-rename id")),
738            "folder limitations must be stated: {:?}",
739            report.limitations
740        );
741        // Touches of the *other* entity (the seed) never appear.
742        assert!(report.touches.iter().all(|t| t.id_at_touch == id));
743    }
744
745    #[test]
746    fn folder_rename_truncates_visibly_not_silently() {
747        let tmp = tempfile::TempDir::new().unwrap();
748        let mut engine = folder_engine(&tmp);
749        let id = create(&mut engine, "Before Rename", "born");
750        let outcome = engine
751            .rename_entity(
752                crate::RenameEntityArgs {
753                    id: crate::EntityId(id.clone()),
754                    expected_hash: None,
755                    new_title: "After Rename".to_string(),
756                },
757                crate::vcs::Actor::Cli,
758                None,
759                Some("renamed"),
760            )
761            .unwrap();
762        let new_id = outcome.new_id.0;
763
764        // The folder changelog records renames under the post-rename
765        // id only — the story under the new id starts at the rename
766        // and SAYS so (never an unexplained short history).
767        let report = engine.entity_history("specs", &new_id, None, None).unwrap();
768        assert_eq!(report.touches[0].verb.as_deref(), Some("rename"));
769        assert!(report.touches[0].renamed_from.is_none());
770        match &report.story_start {
771            super::StoryStart::Truncated { reason } => {
772                assert!(
773                    reason.contains("not the entity's creation"),
774                    "reason must explain the truncation: {reason}"
775                );
776            }
777            other => panic!("expected visible truncation, got {other:?}"),
778        }
779    }
780
781    #[test]
782    fn reused_id_never_absorbs_the_previous_holders_story() {
783        // Rename an entity away, then create a NEW entity under the
784        // freed slug: the newcomer's story must start at ITS create —
785        // pre-fix it absorbed the previous holder's touches and
786        // presented the polluted story as `Recorded` (grading-gate
787        // finding, plan 03/02).
788        let tmp = tempfile::TempDir::new().unwrap();
789        let mut engine = folder_engine(&tmp);
790        let id = create(&mut engine, "Slot", "first holder");
791        update(&mut engine, &id, "first holder grew");
792        engine
793            .rename_entity(
794                crate::RenameEntityArgs {
795                    id: crate::EntityId(id.clone()),
796                    expected_hash: None,
797                    new_title: "Slot Moved".to_string(),
798                },
799                crate::vcs::Actor::Cli,
800                None,
801                Some("moved away"),
802            )
803            .unwrap();
804        let reused = create(&mut engine, "Slot", "second holder");
805        assert_eq!(reused, id, "the slug is reused");
806
807        let report = engine.entity_history("specs", &reused, None, None).unwrap();
808        assert_eq!(report.total_recorded, 1, "only its own birth: {report:#?}");
809        assert_eq!(report.touches[0].verb.as_deref(), Some("create"));
810        assert_eq!(report.touches[0].note.as_deref(), Some("second holder"));
811        assert!(matches!(report.story_start, super::StoryStart::Recorded));
812    }
813
814    #[test]
815    fn refusals_are_typed_never_empty_stories() {
816        let tmp = tempfile::TempDir::new().unwrap();
817        let mut engine = folder_engine(&tmp);
818        let id = create(&mut engine, "Real", "born");
819
820        // Unknown mem.
821        let err = engine.entity_history("ghost", &id, None, None).unwrap_err();
822        assert_eq!(err.code(), "UNKNOWN_MEM");
823        // Unknown entity — never an empty history.
824        let err = engine
825            .entity_history("specs", "specs--nope", None, None)
826            .unwrap_err();
827        assert_eq!(err.code(), "ENTITY_NOT_FOUND");
828        // Garbage cursor.
829        let err = engine
830            .entity_history("specs", &id, None, Some("zzz@0"))
831            .unwrap_err();
832        assert_eq!(err.code(), "INVALID_CURSOR");
833        let err = engine
834            .entity_history("specs", &id, None, Some("no-separator"))
835            .unwrap_err();
836        assert_eq!(err.code(), "INVALID_CURSOR");
837    }
838
839    #[test]
840    fn pre_changelog_entity_states_the_empty_record() {
841        // The seed entity was written outside the engine — no
842        // changelog line exists for it. Its story is empty AND says
843        // why, distinguishable from "exists, untouched" by the stated
844        // truncation.
845        let tmp = tempfile::TempDir::new().unwrap();
846        let engine = folder_engine(&tmp);
847        let report = engine
848            .entity_history("specs", "specs--seed", None, None)
849            .unwrap();
850        assert!(report.touches.is_empty());
851        assert!(matches!(
852            report.story_start,
853            super::StoryStart::Truncated { .. }
854        ));
855    }
856
857    #[test]
858    fn archive_mounts_refuse_rather_than_fabricate_emptiness() {
859        // An archive-declared mount whose backend nonetheless serves
860        // the store: the storage arm must refuse before the seam's
861        // history-free `read_provenance` can masquerade as an empty
862        // story.
863        let dir = tempfile::TempDir::new().unwrap();
864        let backend = crate::storage::InMemoryBackend::new();
865        crate::backend::MemBackend::write_entity(
866            &backend,
867            std::path::Path::new("seed.md"),
868            SEED.as_bytes(),
869        )
870        .unwrap();
871        crate::backend::MemBackend::commit(
872            &backend,
873            "seed",
874            &crate::vcs::CommitContext::internal(),
875        )
876        .unwrap();
877        let mount = crate::Mount {
878            mem: "specs".to_string(),
879            schema: Some(memstead_schema::SchemaRef::new(
880                "default",
881                semver::Version::new(1, 0, 0),
882            )),
883            storage: crate::MountStorage::Archive {
884                path: dir.path().join("sealed.mem"),
885            },
886            capability: crate::MountCapability::Write,
887            lifecycle: crate::MountLifecycle::Eager,
888            cross_linkable: false,
889            migration_target: None,
890        };
891        let engine = crate::Engine::from_mounts(vec![(
892            mount,
893            Box::new(backend) as Box<dyn crate::MemBackend>,
894        )])
895        .unwrap();
896        let err = engine
897            .entity_history("specs", "specs--seed", None, None)
898            .unwrap_err();
899        assert_eq!(err.code(), "INVALID_INPUT");
900    }
901
902    #[test]
903    fn pages_compose_without_gaps_or_duplicates() {
904        let tmp = tempfile::TempDir::new().unwrap();
905        let mut engine = folder_engine(&tmp);
906        let id = create(&mut engine, "Paged", "born");
907        for i in 0..5 {
908            update(&mut engine, &id, &format!("touch {i}"));
909        }
910
911        let full = engine.entity_history("specs", &id, None, None).unwrap();
912        assert_eq!(full.total_recorded, 6);
913        assert!(full.next_cursor.is_none());
914
915        // Walk in pages of 2 and re-compose.
916        let mut collected: Vec<super::EntityTouch> = Vec::new();
917        let mut cursor: Option<String> = None;
918        loop {
919            let page = engine
920                .entity_history("specs", &id, Some(2), cursor.as_deref())
921                .unwrap();
922            assert!(page.touches.len() <= 2);
923            assert_eq!(page.total_recorded, 6, "every page states the whole");
924            collected.extend(page.touches.clone());
925            match page.next_cursor {
926                Some(c) => cursor = Some(c),
927                None => break,
928            }
929        }
930        assert_eq!(
931            collected, full.touches,
932            "paged walk must equal the single-page story exactly"
933        );
934    }
935}