Skip to main content

memstead_base/ops/
changes.rs

1//! Backend-neutral entity-level delta surface for `memstead_changes_since`
2//! callers.
3//!
4//! [`ChangeEnvelope`] is the per-entity event shape ("this entity was
5//! added / updated / removed / renamed between cursor X and the
6//! current state"). [`crate::Engine::changes_since`] dispatches per
7//! mount: folder mounts synthesise from `.memstead/changes.jsonl` via
8//! [`folder_changes_since`]; git-branch mounts call the registered
9//! [`crate::GitBranchOps::changes_since`] dispatcher (real tree-diff
10//! with rename detection); archive mounts return an empty report.
11//!
12//! The cursor format is backend-specific and opaque from the caller's
13//! perspective: a commit SHA for git-branch, an RFC-3339 timestamp
14//! for folder, ignored for archive. The empty-tree-SHA sentinel
15//! ([`EMPTY_TREE_SHA`]) is a convention preserved across backends so
16//! "diff against nothing" works without each backend re-inventing
17//! the same first-poll shape.
18//!
19//! `title` and `entity_type` on the envelope variants are populated
20//! by the engine wrapper from the in-memory store (best-effort —
21//! `Removed` envelopes always leave them `None` because the entity
22//! is gone). Backend dispatchers produce id-only envelopes; the
23//! [`crate::Engine::changes_since`] wrapper enriches.
24
25use std::path::Path;
26
27use serde::Serialize;
28
29use crate::backend::BackendError;
30use crate::entity::EntityId;
31use crate::provenance::ProvenanceKind;
32
33/// Canonical git empty-tree hash. Callers without a prior cursor pass
34/// this to get "every entity in the current state as added". Both
35/// the git-branch backend (special-cased to bypass `rev_parse`) and
36/// any future folder-backend implementation honour the same sentinel.
37pub const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
38
39/// Default content-similarity threshold for rename detection (60%).
40/// Callers override per-call via `changes_since`'s `rename_similarity`
41/// parameter; the engine wrapper accepts `[0.1, 1.0]` and emits a
42/// `LIMIT_CLAMPED` warning for out-of-range values. Higher values
43/// miss edited renames; lower values risk false-positive rename
44/// pairing.
45pub const RENAME_SIMILARITY_DEFAULT: f32 = 0.6;
46
47/// Lower bound for `rename_similarity` — anything below 0.1 produces
48/// nearly-random rewrite pairing on a modest diff.
49pub const RENAME_SIMILARITY_MIN: f32 = 0.1;
50
51/// Upper bound for `rename_similarity` — 1.0 means "only paired up
52/// on a byte-identical match"; above that there is no semantic
53/// meaning.
54pub const RENAME_SIMILARITY_MAX: f32 = 1.0;
55
56/// Single delta entry between two snapshots. `Renamed` collapses what
57/// would otherwise appear as a `Removed` + `Added` pair so agents see
58/// one semantic event per filesystem rename.
59///
60/// `title` and `entity_type` are best-effort enrichment from the
61/// engine's in-memory store: present when the backend's diff resolves
62/// to an entity the engine still knows about, `None` otherwise.
63/// `Removed` envelopes always leave both `None` (the entity is gone
64/// by definition); other variants populate when the lookup succeeds.
65#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
66#[serde(tag = "action", rename_all = "lowercase")]
67pub enum ChangeEnvelope {
68    Added {
69        id: EntityId,
70        #[serde(skip_serializing_if = "Option::is_none")]
71        title: Option<String>,
72        #[serde(skip_serializing_if = "Option::is_none")]
73        entity_type: Option<String>,
74    },
75    Updated {
76        id: EntityId,
77        #[serde(skip_serializing_if = "Option::is_none")]
78        title: Option<String>,
79        #[serde(skip_serializing_if = "Option::is_none")]
80        entity_type: Option<String>,
81    },
82    Removed {
83        id: EntityId,
84        #[serde(skip_serializing_if = "Option::is_none")]
85        title: Option<String>,
86        #[serde(skip_serializing_if = "Option::is_none")]
87        entity_type: Option<String>,
88    },
89    Renamed {
90        from_id: EntityId,
91        to_id: EntityId,
92        #[serde(skip_serializing_if = "Option::is_none")]
93        title: Option<String>,
94        #[serde(skip_serializing_if = "Option::is_none")]
95        entity_type: Option<String>,
96    },
97}
98
99impl ChangeEnvelope {
100    /// The id this change sorts and renders under — `to_id` for a
101    /// rename (the surviving entity), the entity id otherwise.
102    pub fn primary_id(&self) -> &str {
103        match self {
104            ChangeEnvelope::Added { id, .. }
105            | ChangeEnvelope::Updated { id, .. }
106            | ChangeEnvelope::Removed { id, .. } => id.as_ref(),
107            ChangeEnvelope::Renamed { to_id, .. } => to_id.as_ref(),
108        }
109    }
110
111    /// The wire `action` verb — the same token the serde tag emits and
112    /// `memstead_changes_since` reports: `added` | `updated` | `removed` |
113    /// `renamed`.
114    pub fn action(&self) -> &'static str {
115        match self {
116            ChangeEnvelope::Added { .. } => "added",
117            ChangeEnvelope::Updated { .. } => "updated",
118            ChangeEnvelope::Removed { .. } => "removed",
119            ChangeEnvelope::Renamed { .. } => "renamed",
120        }
121    }
122
123    /// Best-effort entity type carried on the envelope (`None` on
124    /// `Removed`, or when the store lookup missed).
125    pub fn entity_type(&self) -> Option<&str> {
126        match self {
127            ChangeEnvelope::Added { entity_type, .. }
128            | ChangeEnvelope::Updated { entity_type, .. }
129            | ChangeEnvelope::Removed { entity_type, .. }
130            | ChangeEnvelope::Renamed { entity_type, .. } => entity_type.as_deref(),
131        }
132    }
133
134    /// The same change with `title` / `entity_type` stripped — the
135    /// `ids`-tier projection of a notice entry. The id (and a rename's
136    /// `from_id` / `to_id` pair) is preserved; it is identity, not rich
137    /// detail.
138    fn without_metadata(&self) -> Self {
139        match self {
140            ChangeEnvelope::Added { id, .. } => ChangeEnvelope::Added {
141                id: id.clone(),
142                title: None,
143                entity_type: None,
144            },
145            ChangeEnvelope::Updated { id, .. } => ChangeEnvelope::Updated {
146                id: id.clone(),
147                title: None,
148                entity_type: None,
149            },
150            ChangeEnvelope::Removed { id, .. } => ChangeEnvelope::Removed {
151                id: id.clone(),
152                title: None,
153                entity_type: None,
154            },
155            ChangeEnvelope::Renamed { from_id, to_id, .. } => ChangeEnvelope::Renamed {
156                from_id: from_id.clone(),
157                to_id: to_id.clone(),
158                title: None,
159                entity_type: None,
160            },
161        }
162    }
163}
164
165/// Backend-neutral "what changed" report. The engine wrapper
166/// ([`crate::Engine::changes_since`], landing in a follow-up session)
167/// adds rename-similarity clamping warnings, optional agent-notes
168/// piggyback (git-branch only), and the operator-facing
169/// `mem: String` field on top.
170///
171/// `head` echoes the resolved cursor of the current state — agents
172/// remember it as the next polling cursor so the next call passes it
173/// straight back as `since` without a `memstead_health` round-trip.
174#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
175pub struct BackendChanges {
176    /// The cursor the caller passed in, echoed verbatim.
177    pub since: String,
178    /// The cursor of the current state — opaque to the caller, but
179    /// stable across consecutive polls when nothing has changed.
180    pub head: String,
181    /// Per-entity events. Empty when nothing changed (or when the
182    /// backend has no native diff and inherits the trait's default
183    /// impl — folder + archive today).
184    pub changes: Vec<ChangeEnvelope>,
185    /// Per-commit agent-notes parsed from commit trailers. Empty for
186    /// backends without commit history (folder, archive). Populated
187    /// by the git-branch backend on every `changes_since` call — the
188    /// walk lives inside the backend so the engine has a single source
189    /// of truth for both the rename map (note-driven) and the
190    /// per-commit feed, and the MCP `include_notes` parameter becomes a
191    /// renderer-side filter rather than a separate engine-side trigger.
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub notes: Vec<crate::ops::agent_notes::CommitNote>,
194    /// Workspace-level `__MEMSTEAD` ref tip (unified schemas + per-mem
195    /// configs). `None` for backends without commit history; `None`
196    /// also on git-branch backends where the `__MEMSTEAD` ref does not
197    /// (yet) exist — pre-migration workspaces are legitimate.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub memstead_ref: Option<String>,
200}
201
202impl BackendChanges {
203    /// Empty report at `since` — the default a backend without a
204    /// native diff returns. `head` echoes `since` so the caller's
205    /// cursor stays stable across polls.
206    pub fn empty_at(since: &str) -> Self {
207        Self {
208            since: since.to_string(),
209            head: since.to_string(),
210            changes: Vec::new(),
211            notes: Vec::new(),
212            memstead_ref: None,
213        }
214    }
215}
216
217/// Synthesise per-entity events for a folder-backed mem by reading
218/// `<mem_root>/.memstead/changes.jsonl` and bucketing events by entity.
219///
220/// Net-effect rules per entity:
221/// - Last event = Delete                  → `Removed`
222/// - First event = Create                 → `Added`
223/// - Anything else                        → `Updated`
224///
225/// `Rename` events surface as `Updated` (folder rename doesn't carry
226/// from→to metadata). `Batch` events have no entity id and don't
227/// contribute envelopes. The cursor is an RFC-3339 timestamp; the
228/// [`EMPTY_TREE_SHA`] sentinel and the empty string mean "from the
229/// beginning". Any other non-parseable cursor **refuses** with the
230/// typed `INVALID_TS_CURSOR:` marker (lifted by the engine to the
231/// `INVALID_CURSOR` code): the folder ledger's timestamps are compared
232/// lexically, and a mutation's `write_id` — fixed-width hex minted
233/// from a nanosecond clock — sorts below every timestamp, so the old
234/// tolerant reading silently replayed the whole history to exactly
235/// the caller who confused the two. `head` echoes the latest
236/// timestamp seen, falling back to `since`.
237///
238/// Envelopes are id-only (`title` / `entity_type` are `None`); the
239/// engine wrapper enriches from its in-memory store.
240pub fn folder_changes_since(
241    mem_root: &Path,
242    mem: &str,
243    since: &str,
244) -> Result<BackendChanges, BackendError> {
245    if !since.is_empty()
246        && since != EMPTY_TREE_SHA
247        && crate::filesystem::changelog::parse_rfc3339_utc(since).is_none()
248    {
249        // Typed marker, same convention as the git backend's
250        // `COMMIT_NOT_FOUND:` — the engine wrapper lifts it to
251        // `EngineError::InvalidTimestampCursor` (code INVALID_CURSOR).
252        return Err(BackendError::Other(format!("INVALID_TS_CURSOR:{since}")));
253    }
254    let log_path = crate::filesystem::changelog::changelog_path(mem_root);
255    let raw = match std::fs::read_to_string(&log_path) {
256        Ok(s) => s,
257        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
258            return Ok(BackendChanges::empty_at(since));
259        }
260        Err(e) => return Err(BackendError::Io(e)),
261    };
262
263    struct Aggregate {
264        first_kind: ProvenanceKind,
265        last_kind: ProvenanceKind,
266    }
267    let mut by_entity: std::collections::BTreeMap<String, Aggregate> =
268        std::collections::BTreeMap::new();
269    let mut max_ts: Option<String> = None;
270
271    let cursor_opt: Option<&str> = if since.is_empty() || since == EMPTY_TREE_SHA {
272        None
273    } else {
274        Some(since)
275    };
276
277    for line in raw.lines() {
278        let trimmed = line.trim();
279        if trimmed.is_empty() {
280            continue;
281        }
282        let value: serde_json::Value = match serde_json::from_str(trimmed) {
283            Ok(v) => v,
284            Err(_) => continue,
285        };
286        let ts_str = value.get("ts").and_then(|v| v.as_str()).unwrap_or("");
287        if let Some(c) = cursor_opt
288            && ts_str <= c
289        {
290            continue;
291        }
292        let kind = match value
293            .get("kind")
294            .and_then(|v| v.as_str())
295            .and_then(ProvenanceKind::parse)
296        {
297            Some(k) => k,
298            None => continue,
299        };
300        let entity_id = match value.get("entity").and_then(|v| v.as_str()) {
301            Some(s) if !s.is_empty() => s.to_string(),
302            _ => continue,
303        };
304
305        if max_ts.as_deref().is_none_or(|m| ts_str > m) {
306            max_ts = Some(ts_str.to_string());
307        }
308
309        by_entity
310            .entry(entity_id)
311            .and_modify(|agg| {
312                agg.last_kind = kind;
313            })
314            .or_insert(Aggregate {
315                first_kind: kind,
316                last_kind: kind,
317            });
318    }
319
320    let mut changes: Vec<ChangeEnvelope> = Vec::with_capacity(by_entity.len());
321    for (entity_str, agg) in by_entity {
322        let id = match entity_str.split_once("--") {
323            Some((v, slug)) if v == mem => EntityId::new(v, slug),
324            _ => continue,
325        };
326        let envelope = match (agg.first_kind, agg.last_kind) {
327            (_, ProvenanceKind::Delete) => ChangeEnvelope::Removed {
328                id,
329                title: None,
330                entity_type: None,
331            },
332            (ProvenanceKind::Create, _) => ChangeEnvelope::Added {
333                id,
334                title: None,
335                entity_type: None,
336            },
337            _ => ChangeEnvelope::Updated {
338                id,
339                title: None,
340                entity_type: None,
341            },
342        };
343        changes.push(envelope);
344    }
345
346    Ok(BackendChanges {
347        since: since.to_string(),
348        head: max_ts.unwrap_or_else(|| since.to_string()),
349        changes,
350        notes: Vec::new(),
351        memstead_ref: None,
352    })
353}
354
355/// Engine-wrapper-level "what changed" shape returned by
356/// [`crate::Engine::changes_since`].
357///
358/// Adds the operator-facing `mem: String` and `warnings:
359/// Vec<WarningHint>` that the engine layer owns (rename-similarity
360/// clamping, etc.) on top of [`BackendChanges`]. Envelope `title` /
361/// `entity_type` fields are enriched from the engine's in-memory
362/// store (best-effort — `Removed` envelopes always leave them
363/// `None`; missing-from-store entities also leave them `None`).
364///
365/// Optional `notes` and `memstead_ref` carry per-commit agent-notes and
366/// the workspace-level `__MEMSTEAD` ref tip when the caller passes
367/// `include_notes: true`. Both fields stay `None` on folder + archive
368/// mounts (no commit history to read). MCP and CLI handlers populate
369/// them for git-branch mounts by pattern-matching on
370/// [`crate::workspace::MountStorage::GitBranch`] and calling
371/// `memstead_git_branch::ops::agent_notes::agent_notes_since` directly.
372#[derive(Debug, Clone, Serialize)]
373pub struct ChangesReport {
374    pub mem: String,
375    pub since: String,
376    pub head: String,
377    pub changes: Vec<ChangeEnvelope>,
378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
379    pub warnings: Vec<crate::ops::WarningHint>,
380    /// Per-commit agent-notes parsed from commit trailers (git-branch
381    /// backend only). `None` when `include_notes` is false or the
382    /// backend has no commit history.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub notes: Option<Vec<crate::ops::agent_notes::CommitNote>>,
385    /// Workspace-level `__MEMSTEAD` ref tip (unified schemas + per-mem
386    /// configs). `None` when `include_notes` is false or the
387    /// workspace has not been migrated to the unified layout yet.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub memstead_ref: Option<String>,
390}
391
392// ---- mem_changed notice (reload-before-op awareness contract) ----
393
394/// Max changed-entity count rendered with full per-entity detail
395/// (id + change-kind + title + type) before the notice degrades to
396/// id+kind only. Rich detail is the expensive part of the payload; an
397/// id is cheap. Below this threshold the notice is `mode: "detailed"`.
398const NOTICE_DETAILED_MAX: usize = 50;
399
400/// Max changed-entity count that still lists every changed id inline
401/// (id + change-kind, `mode: "ids"`). The inline id list is exactly
402/// what lets the agent run its own relevance check as a local
403/// set-intersection against its context in zero round-trips, so it
404/// survives well past the rich-detail threshold. Above this, the
405/// notice collapses to `mode: "counts"` and points the agent at
406/// `memstead_changes_since` for the full delta.
407const NOTICE_IDS_MAX: usize = 500;
408
409/// Per-change-kind counts in `mode: "counts"`. Field names track the
410/// `memstead_changes_since` action vocabulary (`updated`, not `modified`)
411/// so the notice and the recovery surface speak one language.
412#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
413pub struct NoticeByChange {
414    pub added: usize,
415    pub updated: usize,
416    pub removed: usize,
417    pub renamed: usize,
418}
419
420/// The size-graceful body of a [`MemChangedNotice`]. Internally
421/// tagged on `mode` so a caller decodes one stable shape and branches
422/// on the discriminator — no request-shape-dependent polymorphism.
423///
424/// The `detailed` and `ids` tiers carry [`ChangeEnvelope`]s — the exact
425/// per-entity shape `memstead_changes_since` emits (same `action`
426/// vocabulary, `from_id` / `to_id` on renames). Sharing the type is the
427/// point: an agent that follows the notice's `self_inform` to
428/// `memstead_changes_since` decodes one shape on both surfaces, and the two
429/// delta representations cannot drift apart in vocabulary or richness.
430#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
431#[serde(tag = "mode", rename_all = "lowercase")]
432pub enum NoticeChanges {
433    /// Small delta — full per-entity [`ChangeEnvelope`]s with `title` /
434    /// `entity_type` enrichment.
435    Detailed { entries: Vec<ChangeEnvelope> },
436    /// Medium delta — every changed entity inline as a [`ChangeEnvelope`]
437    /// with `title` / `entity_type` stripped. The id list (and a
438    /// rename's `from_id` / `to_id`) stays complete; only rich detail is
439    /// dropped once the delta exceeds [`NOTICE_DETAILED_MAX`].
440    Ids { entries: Vec<ChangeEnvelope> },
441    /// Mass change — counts by type and by change-kind, plus the
442    /// `memstead_changes_since(since=<from_head>)` instruction for the
443    /// full delta. `counts` keys are entity types (omitted for
444    /// envelopes whose type the store couldn't resolve, e.g. removed).
445    Counts {
446        counts: std::collections::BTreeMap<String, usize>,
447        by_change: NoticeByChange,
448        self_inform: String,
449    },
450}
451
452/// Non-blocking "the mem moved under you" notice, attached to a
453/// response only when a reload happened during the operation. The
454/// operation's own result/error rides alongside — this is purely the
455/// objective "what else changed" delta, scaled by size, for the agent
456/// to judge relevance against (the engine does not filter to a
457/// per-agent interest model).
458///
459/// Built by [`MemChangedNotice::from_delta`] from the
460/// `from_head → to_head` [`ChangeEnvelope`] list a reload produced.
461/// Entries are ordered lexically by id so two notices over the same
462/// delta are byte-identical.
463#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
464pub struct MemChangedNotice {
465    pub mem: String,
466    pub from_head: String,
467    pub to_head: String,
468    pub changes: NoticeChanges,
469}
470
471impl MemChangedNotice {
472    /// Build a notice from the per-entity delta between `from_head`
473    /// and `to_head`, degrading by size:
474    /// `detailed` (≤ [`NOTICE_DETAILED_MAX`]) → `ids`
475    /// (≤ [`NOTICE_IDS_MAX`]) → `counts`. Entries are sorted lexically
476    /// by primary id (the `to_id` for a rename) so the output is
477    /// deterministic regardless of input order.
478    pub fn from_delta(
479        mem: String,
480        from_head: String,
481        to_head: String,
482        mut changes: Vec<ChangeEnvelope>,
483    ) -> Self {
484        changes.sort_by(|a, b| a.primary_id().cmp(b.primary_id()));
485        let n = changes.len();
486        let body = if n <= NOTICE_DETAILED_MAX {
487            NoticeChanges::Detailed { entries: changes }
488        } else if n <= NOTICE_IDS_MAX {
489            NoticeChanges::Ids {
490                entries: changes
491                    .iter()
492                    .map(ChangeEnvelope::without_metadata)
493                    .collect(),
494            }
495        } else {
496            let mut counts: std::collections::BTreeMap<String, usize> =
497                std::collections::BTreeMap::new();
498            let mut by_change = NoticeByChange::default();
499            for env in &changes {
500                match env {
501                    ChangeEnvelope::Added { .. } => by_change.added += 1,
502                    ChangeEnvelope::Updated { .. } => by_change.updated += 1,
503                    ChangeEnvelope::Removed { .. } => by_change.removed += 1,
504                    ChangeEnvelope::Renamed { .. } => by_change.renamed += 1,
505                }
506                if let Some(t) = env.entity_type() {
507                    *counts.entry(t.to_string()).or_default() += 1;
508                }
509            }
510            NoticeChanges::Counts {
511                counts,
512                by_change,
513                self_inform: format!("call memstead_changes_since(since={from_head})"),
514            }
515        };
516        Self {
517            mem,
518            from_head,
519            to_head,
520            changes: body,
521        }
522    }
523
524    /// Total changed-entity count this notice describes, across every
525    /// degradation tier. The MCP layer uses it to populate the
526    /// `entities_loaded` field of a `MemReloaded` warning synthesised
527    /// for an error response — a mutation reloads *inside* the engine
528    /// and surfaces only the stashed notice, not a `WarningHint`, so the
529    /// error-text warning line is reconstructed from the notice itself.
530    pub fn entity_count(&self) -> usize {
531        match &self.changes {
532            NoticeChanges::Detailed { entries } | NoticeChanges::Ids { entries } => entries.len(),
533            NoticeChanges::Counts { by_change, .. } => {
534                by_change.added + by_change.updated + by_change.removed + by_change.renamed
535            }
536        }
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn empty_at_echoes_cursor() {
546        let r = BackendChanges::empty_at("abc");
547        assert_eq!(r.since, "abc");
548        assert_eq!(r.head, "abc");
549        assert!(r.changes.is_empty());
550    }
551
552    #[test]
553    fn changes_report_omits_notes_and_memstead_ref_when_none() {
554        // Default-shaped report (no include_notes) — both Optional
555        // fields skip-serialize-when-none. Consumers that don't
556        // request notes see no notes/memstead_ref keys on the wire.
557        let r = ChangesReport {
558            mem: "specs".to_string(),
559            since: "abc".to_string(),
560            head: "def".to_string(),
561            changes: Vec::new(),
562            warnings: Vec::new(),
563            notes: None,
564            memstead_ref: None,
565        };
566        let json = serde_json::to_string(&r).unwrap();
567        assert!(
568            !json.contains("\"notes\""),
569            "notes must be omitted when None: {json}"
570        );
571        assert!(
572            !json.contains("\"memstead_ref\""),
573            "memstead_ref must be omitted when None: {json}"
574        );
575    }
576
577    #[test]
578    fn changes_report_emits_notes_and_memstead_ref_when_some() {
579        // When include_notes populates the fields, wire shape carries
580        // both keys nested at the report root. `memstead_ref` is the SHA
581        // of `refs/heads/__MEMSTEAD` (unified schemas + per-mem configs).
582        let r = ChangesReport {
583            mem: "specs".to_string(),
584            since: "abc".to_string(),
585            head: "def".to_string(),
586            changes: Vec::new(),
587            warnings: Vec::new(),
588            notes: Some(Vec::new()),
589            memstead_ref: Some("aabbccdd".to_string()),
590        };
591        let json = serde_json::to_string(&r).unwrap();
592        assert!(
593            json.contains("\"notes\":["),
594            "notes must be present: {json}"
595        );
596        assert!(
597            json.contains("\"memstead_ref\":\"aabbccdd\""),
598            "memstead_ref must be present and carry the SHA: {json}"
599        );
600    }
601
602    #[test]
603    fn change_envelope_serializes_action_tag() {
604        // Confirms the `action: "added" | "updated" | "removed" |
605        // "renamed"` discriminator is emitted as the wire shape MCP
606        // callers expect — same as full's existing ChangeEnvelope.
607        let env = ChangeEnvelope::Added {
608            id: EntityId::new("specs", "hello"),
609            title: Some("Hello".to_string()),
610            entity_type: Some("spec".to_string()),
611        };
612        let json = serde_json::to_string(&env).unwrap();
613        assert!(json.contains(r#""action":"added""#));
614        assert!(json.contains(r#""title":"Hello""#));
615        assert!(json.contains(r#""entity_type":"spec""#));
616    }
617
618    #[test]
619    fn change_envelope_skips_none_metadata_fields() {
620        let env = ChangeEnvelope::Removed {
621            id: EntityId::new("specs", "gone"),
622            title: None,
623            entity_type: None,
624        };
625        let json = serde_json::to_string(&env).unwrap();
626        assert!(json.contains(r#""action":"removed""#));
627        assert!(!json.contains("\"title\""));
628        assert!(!json.contains("\"entity_type\""));
629    }
630
631    #[test]
632    fn change_envelope_renamed_carries_both_ids() {
633        let env = ChangeEnvelope::Renamed {
634            from_id: EntityId::new("specs", "old"),
635            to_id: EntityId::new("specs", "new"),
636            title: Some("New".to_string()),
637            entity_type: Some("spec".to_string()),
638        };
639        let json = serde_json::to_string(&env).unwrap();
640        assert!(json.contains(r#""action":"renamed""#));
641        assert!(json.contains(r#""from_id":"specs--old""#));
642        assert!(json.contains(r#""to_id":"specs--new""#));
643    }
644
645    // ---- MemChangedNotice degradation + determinism --------------
646
647    /// `n` added envelopes with predictable ids (`e-0000` …) so tests
648    /// can assert ordering and inline-id presence.
649    fn added_envelopes(n: usize) -> Vec<ChangeEnvelope> {
650        (0..n)
651            .map(|i| ChangeEnvelope::Added {
652                id: EntityId::new("specs", &format!("e-{i:04}")),
653                title: Some(format!("Entity {i}")),
654                entity_type: Some("spec".to_string()),
655            })
656            .collect()
657    }
658
659    #[test]
660    fn notice_small_delta_is_detailed_ordered_and_typed() {
661        // Out-of-order input; the notice sorts by id and carries full
662        // detail as `ChangeEnvelope`s — same `action` vocabulary as
663        // `memstead_changes_since` (`updated`, not `modified`).
664        let changes = vec![
665            ChangeEnvelope::Updated {
666                id: EntityId::new("specs", "bbb"),
667                title: Some("Bee".to_string()),
668                entity_type: Some("spec".to_string()),
669            },
670            ChangeEnvelope::Added {
671                id: EntityId::new("specs", "aaa"),
672                title: Some("Ay".to_string()),
673                entity_type: Some("spec".to_string()),
674            },
675        ];
676        let notice = MemChangedNotice::from_delta(
677            "specs".to_string(),
678            "H0".to_string(),
679            "H1".to_string(),
680            changes,
681        );
682        match &notice.changes {
683            NoticeChanges::Detailed { entries } => {
684                assert_eq!(entries.len(), 2);
685                // Lexical by id: aaa before bbb.
686                assert_eq!(entries[0].primary_id(), "specs--aaa");
687                assert_eq!(entries[0].action(), "added");
688                assert_eq!(entries[1].primary_id(), "specs--bbb");
689                assert_eq!(entries[1].action(), "updated");
690                assert_eq!(entries[1].entity_type(), Some("spec"));
691            }
692            other => panic!("expected detailed, got {other:?}"),
693        }
694        let json = serde_json::to_string(&notice).unwrap();
695        assert!(json.contains(r#""mode":"detailed""#));
696        // Notice and changes_since speak one language: `action`/`updated`,
697        // and `entity_type` (not the old `change`/`modified`/`type`).
698        assert!(json.contains(r#""action":"updated""#));
699        assert!(json.contains(r#""entity_type":"spec""#));
700        assert!(
701            !json.contains(r#""change":"#),
702            "no legacy `change` key: {json}"
703        );
704    }
705
706    #[test]
707    fn notice_entry_is_byte_identical_to_changes_since_envelope() {
708        // F3 parity: the notice's detailed-tier entry and the
709        // `memstead_changes_since` event are the *same* serialized shape, so
710        // an agent decodes one and decodes the other — no translation
711        // between `change`/`action` or `modified`/`updated` or
712        // `type`/`entity_type`. Verified by reusing one `ChangeEnvelope`
713        // on both surfaces and comparing the JSON.
714        let env = ChangeEnvelope::Updated {
715            id: EntityId::new("specs", "x"),
716            title: Some("X".to_string()),
717            entity_type: Some("spec".to_string()),
718        };
719        let changes_since_json = serde_json::to_value(&env).unwrap();
720        let notice = MemChangedNotice::from_delta(
721            "specs".to_string(),
722            "H0".to_string(),
723            "H1".to_string(),
724            vec![env],
725        );
726        let notice_json = serde_json::to_value(&notice).unwrap();
727        let entry = &notice_json["changes"]["entries"][0];
728        assert_eq!(
729            entry, &changes_since_json,
730            "notice entry must equal the changes_since envelope verbatim",
731        );
732    }
733
734    #[test]
735    fn notice_renamed_carries_both_ids_and_sorts_under_to_id() {
736        // F2: a rename in the notice carries both prior and new id —
737        // an agent holding the old id can follow it. Parity with
738        // `memstead_changes_since` (from_id + to_id, not remove+add).
739        let changes = vec![ChangeEnvelope::Renamed {
740            from_id: EntityId::new("specs", "old"),
741            to_id: EntityId::new("specs", "new"),
742            title: Some("New".to_string()),
743            entity_type: Some("spec".to_string()),
744        }];
745        let notice = MemChangedNotice::from_delta(
746            "specs".to_string(),
747            "H0".to_string(),
748            "H1".to_string(),
749            changes,
750        );
751        match &notice.changes {
752            NoticeChanges::Detailed { entries } => {
753                assert_eq!(entries[0].primary_id(), "specs--new");
754                assert_eq!(entries[0].action(), "renamed");
755            }
756            other => panic!("expected detailed, got {other:?}"),
757        }
758        let json = serde_json::to_string(&notice).unwrap();
759        assert!(
760            json.contains(r#""from_id":"specs--old""#),
761            "rename carries from_id: {json}"
762        );
763        assert!(
764            json.contains(r#""to_id":"specs--new""#),
765            "rename carries to_id: {json}"
766        );
767    }
768
769    #[test]
770    fn notice_ids_tier_preserves_rename_both_ids() {
771        // F2 holds in the `ids` tier too: rich detail is dropped but a
772        // rename still carries both ids (identity, not detail).
773        let mut changes = added_envelopes(NOTICE_DETAILED_MAX);
774        changes.push(ChangeEnvelope::Renamed {
775            from_id: EntityId::new("specs", "zzz-old"),
776            to_id: EntityId::new("specs", "zzz-new"),
777            title: Some("Z".to_string()),
778            entity_type: Some("spec".to_string()),
779        });
780        let notice = MemChangedNotice::from_delta(
781            "specs".to_string(),
782            "H0".to_string(),
783            "H1".to_string(),
784            changes,
785        );
786        let json = serde_json::to_string(&notice).unwrap();
787        assert!(
788            json.contains(r#""mode":"ids""#),
789            "expected ids tier: {json}"
790        );
791        assert!(json.contains(r#""from_id":"specs--zzz-old""#));
792        assert!(json.contains(r#""to_id":"specs--zzz-new""#));
793        // Rich detail still dropped in the ids tier.
794        assert!(!json.contains(r#""title""#), "ids tier drops title: {json}");
795    }
796
797    #[test]
798    fn notice_medium_delta_degrades_to_ids_with_every_id_inline() {
799        // 60 > NOTICE_DETAILED_MAX (50) but ≤ NOTICE_IDS_MAX (500):
800        // mode drops to "ids" yet every changed id is still listed.
801        let notice = MemChangedNotice::from_delta(
802            "specs".to_string(),
803            "H0".to_string(),
804            "H1".to_string(),
805            added_envelopes(60),
806        );
807        match &notice.changes {
808            NoticeChanges::Ids { entries } => {
809                assert_eq!(entries.len(), 60, "every changed id stays inline");
810                assert_eq!(entries[0].primary_id(), "specs--e-0000");
811            }
812            other => panic!("expected ids, got {other:?}"),
813        }
814        let json = serde_json::to_string(&notice).unwrap();
815        assert!(json.contains(r#""mode":"ids""#));
816        // Rich detail dropped — no title/entity_type keys in ids mode.
817        assert!(!json.contains(r#""title""#));
818        assert!(!json.contains(r#""entity_type""#));
819    }
820
821    #[test]
822    fn notice_id_list_outlives_rich_detail() {
823        // Complement AC: there is a delta size that drops title/type
824        // (mode "ids") while still listing every id — i.e. the id list
825        // is budgeted on a distinctly higher threshold than the detail.
826        let just_over_detail = NOTICE_DETAILED_MAX + 1;
827        let notice = MemChangedNotice::from_delta(
828            "specs".to_string(),
829            "H0".to_string(),
830            "H1".to_string(),
831            added_envelopes(just_over_detail),
832        );
833        match &notice.changes {
834            NoticeChanges::Ids { entries } => {
835                assert_eq!(entries.len(), just_over_detail);
836            }
837            other => panic!("expected ids at {just_over_detail}, got {other:?}"),
838        }
839    }
840
841    #[test]
842    fn notice_mass_delta_degrades_to_counts_with_self_inform() {
843        // > NOTICE_IDS_MAX (500): collapse to counts. by_change sums
844        // every event; counts buckets by type; self_inform names
845        // changes_since with the from_head cursor; no ids inline.
846        let n = NOTICE_IDS_MAX + 1;
847        let notice = MemChangedNotice::from_delta(
848            "specs".to_string(),
849            "H0".to_string(),
850            "H1".to_string(),
851            added_envelopes(n),
852        );
853        match &notice.changes {
854            NoticeChanges::Counts {
855                counts,
856                by_change,
857                self_inform,
858            } => {
859                assert_eq!(by_change.added, n);
860                assert_eq!(counts.get("spec").copied(), Some(n));
861                assert_eq!(self_inform, "call memstead_changes_since(since=H0)");
862            }
863            other => panic!("expected counts, got {other:?}"),
864        }
865        let json = serde_json::to_string(&notice).unwrap();
866        assert!(json.contains(r#""mode":"counts""#));
867        // No per-entity id list at counts scale.
868        assert!(!json.contains("specs--e-"));
869    }
870
871    #[test]
872    fn notice_is_deterministic_regardless_of_input_order() {
873        // Same delta, reversed input → byte-identical JSON.
874        let forward = added_envelopes(20);
875        let mut reversed = forward.clone();
876        reversed.reverse();
877        let a = MemChangedNotice::from_delta(
878            "specs".to_string(),
879            "H0".to_string(),
880            "H1".to_string(),
881            forward,
882        );
883        let b = MemChangedNotice::from_delta(
884            "specs".to_string(),
885            "H0".to_string(),
886            "H1".to_string(),
887            reversed,
888        );
889        assert_eq!(
890            serde_json::to_string(&a).unwrap(),
891            serde_json::to_string(&b).unwrap(),
892        );
893    }
894}