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 ¬ice.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(¬ice).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(¬ice).unwrap();
727 let entry = ¬ice_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 ¬ice.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(¬ice).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(¬ice).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 ¬ice.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(¬ice).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 ¬ice.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 ¬ice.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(¬ice).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}