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