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 any non-parseable cursor are
229/// treated as "from the beginning". `head` echoes the latest
230/// timestamp seen, falling back to `since`.
231///
232/// Envelopes are id-only (`title` / `entity_type` are `None`); the
233/// engine wrapper enriches from its in-memory store.
234pub fn folder_changes_since(
235    mem_root: &Path,
236    mem: &str,
237    since: &str,
238) -> Result<BackendChanges, BackendError> {
239    let log_path = crate::filesystem::changelog::changelog_path(mem_root);
240    let raw = match std::fs::read_to_string(&log_path) {
241        Ok(s) => s,
242        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
243            return Ok(BackendChanges::empty_at(since));
244        }
245        Err(e) => return Err(BackendError::Io(e)),
246    };
247
248    struct Aggregate {
249        first_kind: ProvenanceKind,
250        last_kind: ProvenanceKind,
251    }
252    let mut by_entity: std::collections::BTreeMap<String, Aggregate> =
253        std::collections::BTreeMap::new();
254    let mut max_ts: Option<String> = None;
255
256    let cursor_opt: Option<&str> = if since.is_empty() || since == EMPTY_TREE_SHA {
257        None
258    } else {
259        Some(since)
260    };
261
262    for line in raw.lines() {
263        let trimmed = line.trim();
264        if trimmed.is_empty() {
265            continue;
266        }
267        let value: serde_json::Value = match serde_json::from_str(trimmed) {
268            Ok(v) => v,
269            Err(_) => continue,
270        };
271        let ts_str = value.get("ts").and_then(|v| v.as_str()).unwrap_or("");
272        if let Some(c) = cursor_opt
273            && ts_str <= c
274        {
275            continue;
276        }
277        let kind = match value
278            .get("kind")
279            .and_then(|v| v.as_str())
280            .and_then(ProvenanceKind::parse)
281        {
282            Some(k) => k,
283            None => continue,
284        };
285        let entity_id = match value.get("entity").and_then(|v| v.as_str()) {
286            Some(s) if !s.is_empty() => s.to_string(),
287            _ => continue,
288        };
289
290        if max_ts.as_deref().is_none_or(|m| ts_str > m) {
291            max_ts = Some(ts_str.to_string());
292        }
293
294        by_entity
295            .entry(entity_id)
296            .and_modify(|agg| {
297                agg.last_kind = kind;
298            })
299            .or_insert(Aggregate {
300                first_kind: kind,
301                last_kind: kind,
302            });
303    }
304
305    let mut changes: Vec<ChangeEnvelope> = Vec::with_capacity(by_entity.len());
306    for (entity_str, agg) in by_entity {
307        let id = match entity_str.split_once("--") {
308            Some((v, slug)) if v == mem => EntityId::new(v, slug),
309            _ => continue,
310        };
311        let envelope = match (agg.first_kind, agg.last_kind) {
312            (_, ProvenanceKind::Delete) => ChangeEnvelope::Removed {
313                id,
314                title: None,
315                entity_type: None,
316            },
317            (ProvenanceKind::Create, _) => ChangeEnvelope::Added {
318                id,
319                title: None,
320                entity_type: None,
321            },
322            _ => ChangeEnvelope::Updated {
323                id,
324                title: None,
325                entity_type: None,
326            },
327        };
328        changes.push(envelope);
329    }
330
331    Ok(BackendChanges {
332        since: since.to_string(),
333        head: max_ts.unwrap_or_else(|| since.to_string()),
334        changes,
335        notes: Vec::new(),
336        memstead_ref: None,
337    })
338}
339
340/// Engine-wrapper-level "what changed" shape returned by
341/// [`crate::Engine::changes_since`].
342///
343/// Adds the operator-facing `mem: String` and `warnings:
344/// Vec<WarningHint>` that the engine layer owns (rename-similarity
345/// clamping, etc.) on top of [`BackendChanges`]. Envelope `title` /
346/// `entity_type` fields are enriched from the engine's in-memory
347/// store (best-effort — `Removed` envelopes always leave them
348/// `None`; missing-from-store entities also leave them `None`).
349///
350/// Optional `notes` and `memstead_ref` carry per-commit agent-notes and
351/// the workspace-level `__MEMSTEAD` ref tip when the caller passes
352/// `include_notes: true`. Both fields stay `None` on folder + archive
353/// mounts (no commit history to read). MCP and CLI handlers populate
354/// them for git-branch mounts by pattern-matching on
355/// [`crate::workspace::MountStorage::GitBranch`] and calling
356/// `memstead_git_branch::ops::agent_notes::agent_notes_since` directly.
357#[derive(Debug, Clone, Serialize)]
358pub struct ChangesReport {
359    pub mem: String,
360    pub since: String,
361    pub head: String,
362    pub changes: Vec<ChangeEnvelope>,
363    #[serde(default, skip_serializing_if = "Vec::is_empty")]
364    pub warnings: Vec<crate::ops::WarningHint>,
365    /// Per-commit agent-notes parsed from commit trailers (git-branch
366    /// backend only). `None` when `include_notes` is false or the
367    /// backend has no commit history.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub notes: Option<Vec<crate::ops::agent_notes::CommitNote>>,
370    /// Workspace-level `__MEMSTEAD` ref tip (unified schemas + per-mem
371    /// configs). `None` when `include_notes` is false or the
372    /// workspace has not been migrated to the unified layout yet.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub memstead_ref: Option<String>,
375}
376
377// ---- mem_changed notice (reload-before-op awareness contract) ----
378
379/// Max changed-entity count rendered with full per-entity detail
380/// (id + change-kind + title + type) before the notice degrades to
381/// id+kind only. Rich detail is the expensive part of the payload; an
382/// id is cheap. Below this threshold the notice is `mode: "detailed"`.
383const NOTICE_DETAILED_MAX: usize = 50;
384
385/// Max changed-entity count that still lists every changed id inline
386/// (id + change-kind, `mode: "ids"`). The inline id list is exactly
387/// what lets the agent run its own relevance check as a local
388/// set-intersection against its context in zero round-trips, so it
389/// survives well past the rich-detail threshold. Above this, the
390/// notice collapses to `mode: "counts"` and points the agent at
391/// `memstead_changes_since` for the full delta.
392const NOTICE_IDS_MAX: usize = 500;
393
394/// Per-change-kind counts in `mode: "counts"`. Field names track the
395/// `memstead_changes_since` action vocabulary (`updated`, not `modified`)
396/// so the notice and the recovery surface speak one language.
397#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
398pub struct NoticeByChange {
399    pub added: usize,
400    pub updated: usize,
401    pub removed: usize,
402    pub renamed: usize,
403}
404
405/// The size-graceful body of a [`MemChangedNotice`]. Internally
406/// tagged on `mode` so a caller decodes one stable shape and branches
407/// on the discriminator — no request-shape-dependent polymorphism.
408///
409/// The `detailed` and `ids` tiers carry [`ChangeEnvelope`]s — the exact
410/// per-entity shape `memstead_changes_since` emits (same `action`
411/// vocabulary, `from_id` / `to_id` on renames). Sharing the type is the
412/// point: an agent that follows the notice's `self_inform` to
413/// `memstead_changes_since` decodes one shape on both surfaces, and the two
414/// delta representations cannot drift apart in vocabulary or richness.
415#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
416#[serde(tag = "mode", rename_all = "lowercase")]
417pub enum NoticeChanges {
418    /// Small delta — full per-entity [`ChangeEnvelope`]s with `title` /
419    /// `entity_type` enrichment.
420    Detailed { entries: Vec<ChangeEnvelope> },
421    /// Medium delta — every changed entity inline as a [`ChangeEnvelope`]
422    /// with `title` / `entity_type` stripped. The id list (and a
423    /// rename's `from_id` / `to_id`) stays complete; only rich detail is
424    /// dropped once the delta exceeds [`NOTICE_DETAILED_MAX`].
425    Ids { entries: Vec<ChangeEnvelope> },
426    /// Mass change — counts by type and by change-kind, plus the
427    /// `memstead_changes_since(since=<from_head>)` instruction for the
428    /// full delta. `counts` keys are entity types (omitted for
429    /// envelopes whose type the store couldn't resolve, e.g. removed).
430    Counts {
431        counts: std::collections::BTreeMap<String, usize>,
432        by_change: NoticeByChange,
433        self_inform: String,
434    },
435}
436
437/// Non-blocking "the mem moved under you" notice, attached to a
438/// response only when a reload happened during the operation. The
439/// operation's own result/error rides alongside — this is purely the
440/// objective "what else changed" delta, scaled by size, for the agent
441/// to judge relevance against (the engine does not filter to a
442/// per-agent interest model).
443///
444/// Built by [`MemChangedNotice::from_delta`] from the
445/// `from_head → to_head` [`ChangeEnvelope`] list a reload produced.
446/// Entries are ordered lexically by id so two notices over the same
447/// delta are byte-identical.
448#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
449pub struct MemChangedNotice {
450    pub mem: String,
451    pub from_head: String,
452    pub to_head: String,
453    pub changes: NoticeChanges,
454}
455
456impl MemChangedNotice {
457    /// Build a notice from the per-entity delta between `from_head`
458    /// and `to_head`, degrading by size:
459    /// `detailed` (≤ [`NOTICE_DETAILED_MAX`]) → `ids`
460    /// (≤ [`NOTICE_IDS_MAX`]) → `counts`. Entries are sorted lexically
461    /// by primary id (the `to_id` for a rename) so the output is
462    /// deterministic regardless of input order.
463    pub fn from_delta(
464        mem: String,
465        from_head: String,
466        to_head: String,
467        mut changes: Vec<ChangeEnvelope>,
468    ) -> Self {
469        changes.sort_by(|a, b| a.primary_id().cmp(b.primary_id()));
470        let n = changes.len();
471        let body = if n <= NOTICE_DETAILED_MAX {
472            NoticeChanges::Detailed { entries: changes }
473        } else if n <= NOTICE_IDS_MAX {
474            NoticeChanges::Ids {
475                entries: changes
476                    .iter()
477                    .map(ChangeEnvelope::without_metadata)
478                    .collect(),
479            }
480        } else {
481            let mut counts: std::collections::BTreeMap<String, usize> =
482                std::collections::BTreeMap::new();
483            let mut by_change = NoticeByChange::default();
484            for env in &changes {
485                match env {
486                    ChangeEnvelope::Added { .. } => by_change.added += 1,
487                    ChangeEnvelope::Updated { .. } => by_change.updated += 1,
488                    ChangeEnvelope::Removed { .. } => by_change.removed += 1,
489                    ChangeEnvelope::Renamed { .. } => by_change.renamed += 1,
490                }
491                if let Some(t) = env.entity_type() {
492                    *counts.entry(t.to_string()).or_default() += 1;
493                }
494            }
495            NoticeChanges::Counts {
496                counts,
497                by_change,
498                self_inform: format!("call memstead_changes_since(since={from_head})"),
499            }
500        };
501        Self {
502            mem,
503            from_head,
504            to_head,
505            changes: body,
506        }
507    }
508
509    /// Total changed-entity count this notice describes, across every
510    /// degradation tier. The MCP layer uses it to populate the
511    /// `entities_loaded` field of a `MemReloaded` warning synthesised
512    /// for an error response — a mutation reloads *inside* the engine
513    /// and surfaces only the stashed notice, not a `WarningHint`, so the
514    /// error-text warning line is reconstructed from the notice itself.
515    pub fn entity_count(&self) -> usize {
516        match &self.changes {
517            NoticeChanges::Detailed { entries } | NoticeChanges::Ids { entries } => entries.len(),
518            NoticeChanges::Counts { by_change, .. } => {
519                by_change.added + by_change.updated + by_change.removed + by_change.renamed
520            }
521        }
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn empty_at_echoes_cursor() {
531        let r = BackendChanges::empty_at("abc");
532        assert_eq!(r.since, "abc");
533        assert_eq!(r.head, "abc");
534        assert!(r.changes.is_empty());
535    }
536
537    #[test]
538    fn changes_report_omits_notes_and_memstead_ref_when_none() {
539        // Default-shaped report (no include_notes) — both Optional
540        // fields skip-serialize-when-none. Consumers that don't
541        // request notes see no notes/memstead_ref keys on the wire.
542        let r = ChangesReport {
543            mem: "specs".to_string(),
544            since: "abc".to_string(),
545            head: "def".to_string(),
546            changes: Vec::new(),
547            warnings: Vec::new(),
548            notes: None,
549            memstead_ref: None,
550        };
551        let json = serde_json::to_string(&r).unwrap();
552        assert!(
553            !json.contains("\"notes\""),
554            "notes must be omitted when None: {json}"
555        );
556        assert!(
557            !json.contains("\"memstead_ref\""),
558            "memstead_ref must be omitted when None: {json}"
559        );
560    }
561
562    #[test]
563    fn changes_report_emits_notes_and_memstead_ref_when_some() {
564        // When include_notes populates the fields, wire shape carries
565        // both keys nested at the report root. `memstead_ref` is the SHA
566        // of `refs/heads/__MEMSTEAD` (unified schemas + per-mem configs).
567        let r = ChangesReport {
568            mem: "specs".to_string(),
569            since: "abc".to_string(),
570            head: "def".to_string(),
571            changes: Vec::new(),
572            warnings: Vec::new(),
573            notes: Some(Vec::new()),
574            memstead_ref: Some("aabbccdd".to_string()),
575        };
576        let json = serde_json::to_string(&r).unwrap();
577        assert!(
578            json.contains("\"notes\":["),
579            "notes must be present: {json}"
580        );
581        assert!(
582            json.contains("\"memstead_ref\":\"aabbccdd\""),
583            "memstead_ref must be present and carry the SHA: {json}"
584        );
585    }
586
587    #[test]
588    fn change_envelope_serializes_action_tag() {
589        // Confirms the `action: "added" | "updated" | "removed" |
590        // "renamed"` discriminator is emitted as the wire shape MCP
591        // callers expect — same as full's existing ChangeEnvelope.
592        let env = ChangeEnvelope::Added {
593            id: EntityId::new("specs", "hello"),
594            title: Some("Hello".to_string()),
595            entity_type: Some("spec".to_string()),
596        };
597        let json = serde_json::to_string(&env).unwrap();
598        assert!(json.contains(r#""action":"added""#));
599        assert!(json.contains(r#""title":"Hello""#));
600        assert!(json.contains(r#""entity_type":"spec""#));
601    }
602
603    #[test]
604    fn change_envelope_skips_none_metadata_fields() {
605        let env = ChangeEnvelope::Removed {
606            id: EntityId::new("specs", "gone"),
607            title: None,
608            entity_type: None,
609        };
610        let json = serde_json::to_string(&env).unwrap();
611        assert!(json.contains(r#""action":"removed""#));
612        assert!(!json.contains("\"title\""));
613        assert!(!json.contains("\"entity_type\""));
614    }
615
616    #[test]
617    fn change_envelope_renamed_carries_both_ids() {
618        let env = ChangeEnvelope::Renamed {
619            from_id: EntityId::new("specs", "old"),
620            to_id: EntityId::new("specs", "new"),
621            title: Some("New".to_string()),
622            entity_type: Some("spec".to_string()),
623        };
624        let json = serde_json::to_string(&env).unwrap();
625        assert!(json.contains(r#""action":"renamed""#));
626        assert!(json.contains(r#""from_id":"specs--old""#));
627        assert!(json.contains(r#""to_id":"specs--new""#));
628    }
629
630    // ---- MemChangedNotice degradation + determinism --------------
631
632    /// `n` added envelopes with predictable ids (`e-0000` …) so tests
633    /// can assert ordering and inline-id presence.
634    fn added_envelopes(n: usize) -> Vec<ChangeEnvelope> {
635        (0..n)
636            .map(|i| ChangeEnvelope::Added {
637                id: EntityId::new("specs", &format!("e-{i:04}")),
638                title: Some(format!("Entity {i}")),
639                entity_type: Some("spec".to_string()),
640            })
641            .collect()
642    }
643
644    #[test]
645    fn notice_small_delta_is_detailed_ordered_and_typed() {
646        // Out-of-order input; the notice sorts by id and carries full
647        // detail as `ChangeEnvelope`s — same `action` vocabulary as
648        // `memstead_changes_since` (`updated`, not `modified`).
649        let changes = vec![
650            ChangeEnvelope::Updated {
651                id: EntityId::new("specs", "bbb"),
652                title: Some("Bee".to_string()),
653                entity_type: Some("spec".to_string()),
654            },
655            ChangeEnvelope::Added {
656                id: EntityId::new("specs", "aaa"),
657                title: Some("Ay".to_string()),
658                entity_type: Some("spec".to_string()),
659            },
660        ];
661        let notice = MemChangedNotice::from_delta(
662            "specs".to_string(),
663            "H0".to_string(),
664            "H1".to_string(),
665            changes,
666        );
667        match &notice.changes {
668            NoticeChanges::Detailed { entries } => {
669                assert_eq!(entries.len(), 2);
670                // Lexical by id: aaa before bbb.
671                assert_eq!(entries[0].primary_id(), "specs--aaa");
672                assert_eq!(entries[0].action(), "added");
673                assert_eq!(entries[1].primary_id(), "specs--bbb");
674                assert_eq!(entries[1].action(), "updated");
675                assert_eq!(entries[1].entity_type(), Some("spec"));
676            }
677            other => panic!("expected detailed, got {other:?}"),
678        }
679        let json = serde_json::to_string(&notice).unwrap();
680        assert!(json.contains(r#""mode":"detailed""#));
681        // Notice and changes_since speak one language: `action`/`updated`,
682        // and `entity_type` (not the old `change`/`modified`/`type`).
683        assert!(json.contains(r#""action":"updated""#));
684        assert!(json.contains(r#""entity_type":"spec""#));
685        assert!(
686            !json.contains(r#""change":"#),
687            "no legacy `change` key: {json}"
688        );
689    }
690
691    #[test]
692    fn notice_entry_is_byte_identical_to_changes_since_envelope() {
693        // F3 parity: the notice's detailed-tier entry and the
694        // `memstead_changes_since` event are the *same* serialized shape, so
695        // an agent decodes one and decodes the other — no translation
696        // between `change`/`action` or `modified`/`updated` or
697        // `type`/`entity_type`. Verified by reusing one `ChangeEnvelope`
698        // on both surfaces and comparing the JSON.
699        let env = ChangeEnvelope::Updated {
700            id: EntityId::new("specs", "x"),
701            title: Some("X".to_string()),
702            entity_type: Some("spec".to_string()),
703        };
704        let changes_since_json = serde_json::to_value(&env).unwrap();
705        let notice = MemChangedNotice::from_delta(
706            "specs".to_string(),
707            "H0".to_string(),
708            "H1".to_string(),
709            vec![env],
710        );
711        let notice_json = serde_json::to_value(&notice).unwrap();
712        let entry = &notice_json["changes"]["entries"][0];
713        assert_eq!(
714            entry, &changes_since_json,
715            "notice entry must equal the changes_since envelope verbatim",
716        );
717    }
718
719    #[test]
720    fn notice_renamed_carries_both_ids_and_sorts_under_to_id() {
721        // F2: a rename in the notice carries both prior and new id —
722        // an agent holding the old id can follow it. Parity with
723        // `memstead_changes_since` (from_id + to_id, not remove+add).
724        let changes = vec![ChangeEnvelope::Renamed {
725            from_id: EntityId::new("specs", "old"),
726            to_id: EntityId::new("specs", "new"),
727            title: Some("New".to_string()),
728            entity_type: Some("spec".to_string()),
729        }];
730        let notice = MemChangedNotice::from_delta(
731            "specs".to_string(),
732            "H0".to_string(),
733            "H1".to_string(),
734            changes,
735        );
736        match &notice.changes {
737            NoticeChanges::Detailed { entries } => {
738                assert_eq!(entries[0].primary_id(), "specs--new");
739                assert_eq!(entries[0].action(), "renamed");
740            }
741            other => panic!("expected detailed, got {other:?}"),
742        }
743        let json = serde_json::to_string(&notice).unwrap();
744        assert!(
745            json.contains(r#""from_id":"specs--old""#),
746            "rename carries from_id: {json}"
747        );
748        assert!(
749            json.contains(r#""to_id":"specs--new""#),
750            "rename carries to_id: {json}"
751        );
752    }
753
754    #[test]
755    fn notice_ids_tier_preserves_rename_both_ids() {
756        // F2 holds in the `ids` tier too: rich detail is dropped but a
757        // rename still carries both ids (identity, not detail).
758        let mut changes = added_envelopes(NOTICE_DETAILED_MAX);
759        changes.push(ChangeEnvelope::Renamed {
760            from_id: EntityId::new("specs", "zzz-old"),
761            to_id: EntityId::new("specs", "zzz-new"),
762            title: Some("Z".to_string()),
763            entity_type: Some("spec".to_string()),
764        });
765        let notice = MemChangedNotice::from_delta(
766            "specs".to_string(),
767            "H0".to_string(),
768            "H1".to_string(),
769            changes,
770        );
771        let json = serde_json::to_string(&notice).unwrap();
772        assert!(
773            json.contains(r#""mode":"ids""#),
774            "expected ids tier: {json}"
775        );
776        assert!(json.contains(r#""from_id":"specs--zzz-old""#));
777        assert!(json.contains(r#""to_id":"specs--zzz-new""#));
778        // Rich detail still dropped in the ids tier.
779        assert!(!json.contains(r#""title""#), "ids tier drops title: {json}");
780    }
781
782    #[test]
783    fn notice_medium_delta_degrades_to_ids_with_every_id_inline() {
784        // 60 > NOTICE_DETAILED_MAX (50) but ≤ NOTICE_IDS_MAX (500):
785        // mode drops to "ids" yet every changed id is still listed.
786        let notice = MemChangedNotice::from_delta(
787            "specs".to_string(),
788            "H0".to_string(),
789            "H1".to_string(),
790            added_envelopes(60),
791        );
792        match &notice.changes {
793            NoticeChanges::Ids { entries } => {
794                assert_eq!(entries.len(), 60, "every changed id stays inline");
795                assert_eq!(entries[0].primary_id(), "specs--e-0000");
796            }
797            other => panic!("expected ids, got {other:?}"),
798        }
799        let json = serde_json::to_string(&notice).unwrap();
800        assert!(json.contains(r#""mode":"ids""#));
801        // Rich detail dropped — no title/entity_type keys in ids mode.
802        assert!(!json.contains(r#""title""#));
803        assert!(!json.contains(r#""entity_type""#));
804    }
805
806    #[test]
807    fn notice_id_list_outlives_rich_detail() {
808        // Complement AC: there is a delta size that drops title/type
809        // (mode "ids") while still listing every id — i.e. the id list
810        // is budgeted on a distinctly higher threshold than the detail.
811        let just_over_detail = NOTICE_DETAILED_MAX + 1;
812        let notice = MemChangedNotice::from_delta(
813            "specs".to_string(),
814            "H0".to_string(),
815            "H1".to_string(),
816            added_envelopes(just_over_detail),
817        );
818        match &notice.changes {
819            NoticeChanges::Ids { entries } => {
820                assert_eq!(entries.len(), just_over_detail);
821            }
822            other => panic!("expected ids at {just_over_detail}, got {other:?}"),
823        }
824    }
825
826    #[test]
827    fn notice_mass_delta_degrades_to_counts_with_self_inform() {
828        // > NOTICE_IDS_MAX (500): collapse to counts. by_change sums
829        // every event; counts buckets by type; self_inform names
830        // changes_since with the from_head cursor; no ids inline.
831        let n = NOTICE_IDS_MAX + 1;
832        let notice = MemChangedNotice::from_delta(
833            "specs".to_string(),
834            "H0".to_string(),
835            "H1".to_string(),
836            added_envelopes(n),
837        );
838        match &notice.changes {
839            NoticeChanges::Counts {
840                counts,
841                by_change,
842                self_inform,
843            } => {
844                assert_eq!(by_change.added, n);
845                assert_eq!(counts.get("spec").copied(), Some(n));
846                assert_eq!(self_inform, "call memstead_changes_since(since=H0)");
847            }
848            other => panic!("expected counts, got {other:?}"),
849        }
850        let json = serde_json::to_string(&notice).unwrap();
851        assert!(json.contains(r#""mode":"counts""#));
852        // No per-entity id list at counts scale.
853        assert!(!json.contains("specs--e-"));
854    }
855
856    #[test]
857    fn notice_is_deterministic_regardless_of_input_order() {
858        // Same delta, reversed input → byte-identical JSON.
859        let forward = added_envelopes(20);
860        let mut reversed = forward.clone();
861        reversed.reverse();
862        let a = MemChangedNotice::from_delta(
863            "specs".to_string(),
864            "H0".to_string(),
865            "H1".to_string(),
866            forward,
867        );
868        let b = MemChangedNotice::from_delta(
869            "specs".to_string(),
870            "H0".to_string(),
871            "H1".to_string(),
872            reversed,
873        );
874        assert_eq!(
875            serde_json::to_string(&a).unwrap(),
876            serde_json::to_string(&b).unwrap(),
877        );
878    }
879}