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