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