Skip to main content

memstead_base/engine/
drift.rs

1//! Drift detection and per-mem change synthesis.
2//!
3//! `reload_if_stale` probes each candidate mount's `current_head()`
4//! cursor on every operation (no throttle), reloads mems whose
5//! on-disk state has advanced past the engine's cached head, and
6//! surfaces `MemReloaded` warnings for handlers that need to
7//! re-derive conclusions from a now-reloaded snapshot. `changes_since`
8//! produces
9//! the per-entity diff between a stored cursor and the backend's
10//! current state — folder mounts synthesise from the changelog, the
11//! git-branch hook walks the tree with rename detection, archive
12//! mounts return empty.
13
14use crate::backend::BackendError;
15use crate::workspace::MountStorage;
16
17use super::mutation::lookup_title_and_type;
18use super::{Engine, EngineError};
19
20impl Engine {
21    /// Reload-before-operation: before any read or write executes,
22    /// check the mem ref; if it advanced past the engine's cached
23    /// `last_known_head`, reload the affected mem(s) and return one
24    /// [`WarningHint::MemReloaded`] per reload so the caller can
25    /// surface the drift to the agent (the response *itself* already
26    /// carries fresh content — the warning explains why state
27    /// shifted).
28    ///
29    /// The ref check runs on **every** call — there is no throttle
30    /// window. A per-operation `current_head()` read is microseconds,
31    /// effectively free at LLM latencies, and a throttle that let an
32    /// operation execute against an already-moved ref would reintroduce
33    /// the exact silent-staleness this guards against. This is the
34    /// correctness floor: no operation acts on a projection that is
35    /// behind git truth.
36    ///
37    /// `mem = Some(name)` scopes the probe to one mount; `None`
38    /// scans every mount. Read handlers that target a known mem
39    /// (`memstead_entity` derives the mem from the id;
40    /// `memstead_changes_since` takes it as a param) and every mutation
41    /// (which knows its target mem) pass a name; tools that scan
42    /// multi-mem (`memstead_search` without a mem filter,
43    /// `memstead_overview`, `memstead_health`) pass `None`.
44    ///
45    /// Behaviour matrix per mount:
46    /// - cached `Some(old)` + on-disk `Some(new)`, `old != new` →
47    ///   reload the mem, emit `MemReloaded`, refresh the cached
48    ///   head to `new`.
49    /// - cached `None` + on-disk `Some(new)` → silently capture the
50    ///   first observed head as the baseline (no warning — there's
51    ///   no prior in-memory snapshot to be stale against).
52    /// - cached / on-disk match, on-disk `None` (folder, archive,
53    ///   refdb hiccup), or `current_head` errors → no-op.
54    ///
55    /// Reload errors are warn-logged and the affected mem is
56    /// skipped — the caller's response is still served from the (now
57    /// stale) in-memory snapshot rather than failing the entire
58    /// request. The next operation retries.
59    ///
60    /// Cache invalidation rides on `reload_one_mem` — community
61    /// and search-index memos drop when any mem reloads.
62    pub fn reload_if_stale(&mut self, mem: Option<&str>) -> Vec<crate::ops::WarningHint> {
63        // Phase 1 — pick candidate mem names that match the filter.
64        // Cloned so the immutable borrow doesn't survive into the
65        // mutation phase.
66        let candidates: Vec<String> = self
67            .mounts
68            .iter()
69            .filter(|m| mem.is_none_or(|v| m.mount.mem == v))
70            .map(|m| m.mount.mem.clone())
71            .collect();
72
73        if candidates.is_empty() {
74            return Vec::new();
75        }
76
77        // Phase 2 — probe every candidate's current head via the
78        // backend. Errors collapse to None so a transient backend
79        // hiccup doesn't surface as a warning; the next operation
80        // retries.
81        let probes: Vec<(String, Option<String>, Option<String>)> = candidates
82            .iter()
83            .filter_map(|name| {
84                let m = self.mounts.iter().find(|m| &m.mount.mem == name)?;
85                let new_head = m.backend.current_head().ok().flatten();
86                let cached = m.last_known_head.clone();
87                Some((name.clone(), cached, new_head))
88            })
89            .collect();
90
91        // Phase 3 — act on each probe. The drift case is the only
92        // one that calls `reload_one_mem`; every other arm just
93        // (for first-observation) captures the baseline head silently.
94        let mut warnings = Vec::new();
95        for (name, cached, new_head) in probes {
96            match (cached, new_head.clone()) {
97                (Some(old), Some(new)) if old != new => {
98                    match self.reload_one_mem(&name) {
99                        Ok(report) => {
100                            warnings.push(crate::ops::WarningHint::MemReloaded {
101                                mem: name.clone(),
102                                old_head: old.clone(),
103                                new_head: new.clone(),
104                                entities_loaded: report.added.len() + report.changed.len(),
105                            });
106                            if let Some(state) =
107                                self.mounts.iter_mut().find(|m| m.mount.mem == name)
108                            {
109                                state.last_known_head = Some(new.clone());
110                            }
111                            // Build the structured notice now — the
112                            // backend's current head equals `new` and no
113                            // follow-on write in this operation has
114                            // committed yet, so the `old → new` delta
115                            // describes only the sibling's change. Stashed
116                            // for the response layer to drain.
117                            let notice = self.mem_changed_notice(&name, &old, &new);
118                            self.pending_mem_changed.push(notice);
119                            // Emit the same change on the mem-change
120                            // event channel: subscribers (SSE forwarders
121                            // foremost) previously saw only this engine's
122                            // own writes — a sibling process's commit,
123                            // detected here as drift, is every bit as
124                            // much a change. `n_commits: 1` per the
125                            // watcher precedent (events batch by
126                            // detection, not by commit archaeology).
127                            self.emit_mem_changed(&crate::engine::events::MemChangedEvent {
128                                mem: name.clone(),
129                                head: new.clone(),
130                                previous: old.clone(),
131                                n_commits: 1,
132                            });
133                        }
134                        Err(e) => {
135                            tracing::warn!(
136                                mem = %name,
137                                error = %e,
138                                "drift-detected reload_one_mem failed; serving \
139                                 stale snapshot — will retry on the next operation"
140                            );
141                        }
142                    }
143                }
144                _ => {
145                    if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name)
146                        && state.last_known_head.is_none()
147                    {
148                        state.last_known_head = new_head;
149                    }
150                }
151            }
152        }
153
154        warnings
155    }
156
157    /// Mark `mount_idx`'s on-disk head as advanced by *this* engine's
158    /// own write so the next `reload_if_stale` doesn't surface
159    /// `MEM_RELOADED` for the commit we just produced. Mutation
160    /// paths call this immediately after `backend.commit` returns —
161    /// the cached `last_known_head` jumps straight to the new SHA
162    /// without going through a reload. Because every mutation runs
163    /// `reload_if_stale` for its target mem *before* committing,
164    /// the cached `last_known_head` is current at commit time, so
165    /// this advance is over a verified parent — it can never jump
166    /// the cache past an unobserved sibling commit. Cross-session and
167    /// out-of-band advances (sibling engine, manual `git pull`) that
168    /// land before the next operation still mismatch the cached value
169    /// and fire the warning as before.
170    ///
171    /// Empty SHA is a no-op (no commit landed — e.g. duplicate-add
172    /// relate). Backends that don't track a head (folder, archive)
173    /// leave `last_known_head` at `None` and still no-op via the
174    /// drift-check's `cached: None` branch.
175    pub(crate) fn record_self_write(&mut self, mount_idx: usize, commit_sha: &str) {
176        if commit_sha.is_empty() {
177            return;
178        }
179        // Capture the pre-write head + mem name so the
180        // `MemChangedEvent` we emit reflects the transition the
181        // current commit produced. We do this before mutating
182        // `last_known_head` because that field is the previous SHA
183        // from the event's point of view.
184        let (mem, previous) = match self.mounts.get(mount_idx) {
185            Some(state) => (
186                state.mount.mem.clone(),
187                state.last_known_head.clone().unwrap_or_default(),
188            ),
189            None => return,
190        };
191        // The recorded head must equal what the backend's next
192        // `current_head()` probe will report, or every self-write
193        // would look like sibling drift on the following operation.
194        // For git-branch backends the probe returns exactly the commit
195        // SHA just produced; the folder backend's drift cursor is the
196        // changelog's last-line timestamp (a different dialect from
197        // its synthetic commit id), so probe once and prefer the
198        // backend's answer. Probe errors fall back to the commit id —
199        // drift detection stays best-effort, never blocking the write.
200        let recorded = self
201            .mounts
202            .get(mount_idx)
203            .and_then(|state| state.backend.current_head().ok().flatten())
204            .unwrap_or_else(|| commit_sha.to_string());
205        if let Some(state) = self.mounts.get_mut(mount_idx) {
206            state.last_known_head = Some(recorded.clone());
207        }
208        // Skip emit when no SHA actually advanced — folder backends
209        // (and archive backends) carry `last_known_head: None` and
210        // pass `commit_sha = ""` in some paths; the early-return at
211        // the top already catches the explicit empty case, but
212        // `previous == commit_sha` covers idempotent re-writes that
213        // pass through the same write path (e.g. a relate that
214        // re-applies the same edge). Skipping keeps the event stream
215        // a stream of *changes* rather than a stream of *writes*.
216        if previous == recorded {
217            return;
218        }
219        let event = crate::engine::events::MemChangedEvent {
220            mem,
221            // The corrected head, not the raw commit id: consumers feed
222            // event heads into `changes_since`, whose folder dialect is
223            // the changelog-timestamp cursor `recorded` carries.
224            head: recorded,
225            previous,
226            n_commits: 1,
227        };
228        self.emit_mem_changed(&event);
229    }
230
231    /// Drain the reload-before-operation notices accumulated since the
232    /// last drain. The response layer calls this after an operation
233    /// completes to attach the structured `mem_changed` notice. Every
234    /// handler that can trigger a reload (directly via
235    /// [`Self::reload_if_stale`] or indirectly through a mutation) must
236    /// drain, or an undrained notice leaks into the next operation's
237    /// response.
238    pub fn take_mem_changed_notices(&mut self) -> Vec<crate::ops::MemChangedNotice> {
239        std::mem::take(&mut self.pending_mem_changed)
240    }
241
242    /// Build a [`crate::ops::MemChangedNotice`] describing the
243    /// per-entity delta a reload applied to `mem` (from `from_head`
244    /// to `to_head`). Derived from [`Self::changes_since`] so it
245    /// carries rename detection on git-branch mounts; on any backend
246    /// error (e.g. an unresolvable cursor) it falls back to an empty
247    /// delta — the heads alone still tell the agent the mem moved.
248    ///
249    /// Callers pair this with [`Self::reload_if_stale`]: a returned
250    /// [`crate::ops::WarningHint::MemReloaded`] carries the
251    /// `old_head` / `new_head` to pass here. The delta matches the
252    /// transition the reload applied (`changes_since` walks the same
253    /// `from_head → current` range).
254    pub fn mem_changed_notice(
255        &self,
256        mem: &str,
257        from_head: &str,
258        to_head: &str,
259    ) -> crate::ops::MemChangedNotice {
260        let changes = self
261            .changes_since(mem, from_head, None)
262            .map(|r| r.changes)
263            .unwrap_or_default();
264        crate::ops::MemChangedNotice::from_delta(
265            mem.to_string(),
266            from_head.to_string(),
267            to_head.to_string(),
268            changes,
269        )
270    }
271
272    /// Per-entity events for `mem` between `since` and the backend's
273    /// current state.
274    ///
275    /// 1. Resolves the mount (returns [`EngineError::UnknownMem`]
276    ///    on unknown mem).
277    /// 2. Validates `rename_similarity` against
278    ///    `[RENAME_SIMILARITY_MIN, RENAME_SIMILARITY_MAX]`. Out-of-range
279    ///    values refuse with [`EngineError::InvalidInput`] carrying
280    ///    `details.allowed_range` and `details.requested`. `None` falls
281    ///    back to [`crate::ops::RENAME_SIMILARITY_DEFAULT`].
282    /// 3. Dispatches on the mount's `MountStorage`:
283    ///    - Folder mounts synthesize from the JSONL changelog via
284    ///      [`crate::ops::folder_changes_since`].
285    ///    - Git-branch mounts call the registered
286    ///      [`GitBranchOps::changes_since`] hook (real tree-diff with
287    ///      rename detection); missing hook = full flavour not loaded
288    ///      and the report comes back empty.
289    ///    - Archive mounts return an empty report.
290    /// 4. Enriches each envelope's `title` / `entity_type` from the
291    ///    in-memory store (best-effort — `Removed` envelopes always
292    ///    leave both `None`; missing-from-store entities also leave
293    ///    them `None`).
294    /// 5. Returns [`crate::ops::ChangesReport`] with `mem`,
295    ///    `since` (echoed), `head` (backend-resolved current
296    ///    cursor), enriched `changes`, and any clamping warnings.
297    pub fn changes_since(
298        &self,
299        mem: &str,
300        since: &str,
301        rename_similarity: Option<f32>,
302    ) -> Result<crate::ops::ChangesReport, EngineError> {
303        let m = self.find_mount(mem)?;
304
305        // Reject out-of-range `rename_similarity` early so CLI and MCP
306        // share one refusal surface.
307        // The prior clamp+warn shape silently accepted nonsense values
308        // (e.g. 1.5 ≡ 1.0); typed refusal gives the agent a recoverable
309        // signal.
310        if let Some(v) = rename_similarity
311            && !(crate::ops::RENAME_SIMILARITY_MIN..=crate::ops::RENAME_SIMILARITY_MAX).contains(&v)
312        {
313            return Err(EngineError::RenameSimilarityOutOfRange {
314                requested: v,
315                allowed_min: crate::ops::RENAME_SIMILARITY_MIN,
316                allowed_max: crate::ops::RENAME_SIMILARITY_MAX,
317            });
318        }
319        let clamped = rename_similarity.unwrap_or(crate::ops::RENAME_SIMILARITY_DEFAULT);
320
321        let backend_changes = match &m.mount.storage {
322            MountStorage::Folder { path } => {
323                crate::ops::folder_changes_since(path, mem, since).map_err(EngineError::Backend)?
324            }
325            MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
326                Some(hook) => match (hook.changes_since)(gitdir, branch, mem, since, clamped) {
327                    Ok(c) => c,
328                    // Lift the backend's typed bad-`since` marker to a typed
329                    // engine error carrying the untruncated SHA, parallel
330                    // to the UNKNOWN_REMOTE / LOCAL_DIVERGENCE prefixes.
331                    Err(BackendError::Other(msg)) if msg.starts_with("COMMIT_NOT_FOUND:") => {
332                        let since = msg
333                            .strip_prefix("COMMIT_NOT_FOUND:")
334                            .unwrap_or_default()
335                            .to_string();
336                        return Err(EngineError::InvalidChangesCursor {
337                            mem: mem.to_string(),
338                            since,
339                        });
340                    }
341                    Err(e) => return Err(EngineError::Backend(e)),
342                },
343                None => crate::ops::BackendChanges::empty_at(since),
344            },
345            // Archive is sealed; the in-memory backend keeps a
346            // provenance log but no cursor-addressable change history,
347            // so both yield no backend-derived changes here (the live
348            // playground stream rides the engine's event broadcast, not
349            // this path).
350            MountStorage::Archive { .. } | MountStorage::InMemory => {
351                crate::ops::BackendChanges::empty_at(since)
352            }
353        };
354
355        // Enrich each id-only envelope from the engine's store.
356        // `Removed` always leaves title / entity_type None — the
357        // entity is gone by definition; the post-reload store does
358        // not have it. Other variants populate when the lookup
359        // succeeds; missing ids stay None.
360        let enriched: Vec<crate::ops::ChangeEnvelope> = backend_changes
361            .changes
362            .into_iter()
363            .map(|env| match env {
364                crate::ops::ChangeEnvelope::Added { id, .. } => {
365                    let (title, entity_type) = lookup_title_and_type(&self.store, &id);
366                    crate::ops::ChangeEnvelope::Added {
367                        id,
368                        title,
369                        entity_type,
370                    }
371                }
372                crate::ops::ChangeEnvelope::Updated { id, .. } => {
373                    let (title, entity_type) = lookup_title_and_type(&self.store, &id);
374                    crate::ops::ChangeEnvelope::Updated {
375                        id,
376                        title,
377                        entity_type,
378                    }
379                }
380                crate::ops::ChangeEnvelope::Removed { id, .. } => {
381                    crate::ops::ChangeEnvelope::Removed {
382                        id,
383                        title: None,
384                        entity_type: None,
385                    }
386                }
387                crate::ops::ChangeEnvelope::Renamed { from_id, to_id, .. } => {
388                    let (title, entity_type) = lookup_title_and_type(&self.store, &to_id);
389                    crate::ops::ChangeEnvelope::Renamed {
390                        from_id,
391                        to_id,
392                        title,
393                        entity_type,
394                    }
395                }
396            })
397            .collect();
398
399        // Out-of-range `rename_similarity` is now a hard refusal (see
400        // early-return above); the response carries no clamping warning.
401        let warnings: Vec<crate::ops::WarningHint> = Vec::new();
402
403        // The backend populates
404        // notes + memstead_ref on every git-branch call (folder + archive
405        // backends leave them empty / None). Surface them
406        // unconditionally; the MCP `include_notes` parameter becomes
407        // a renderer-side filter rather than a separate engine call.
408        let notes = if backend_changes.notes.is_empty() && backend_changes.memstead_ref.is_none() {
409            None
410        } else {
411            Some(backend_changes.notes)
412        };
413        Ok(crate::ops::ChangesReport {
414            mem: mem.to_string(),
415            since: backend_changes.since,
416            head: backend_changes.head,
417            changes: enriched,
418            warnings,
419            notes,
420            memstead_ref: backend_changes.memstead_ref,
421        })
422    }
423
424    /// Fetch updates from `remote` into the workspace's mem-repo.
425    /// Advances remote-tracking refs only; the local branch pointer
426    /// is not moved.
427    ///
428    /// `refspecs` is forwarded verbatim to `git fetch`. An empty list
429    /// uses the remote's configured defaults.
430    ///
431    /// Refusal codes: `UNKNOWN_MEM`, `UNKNOWN_REMOTE`,
432    /// `INVALID_INPUT` (folder / archive mounts).
433    ///
434    /// V1 atomicity: schema-validation quarantine for
435    /// fetched commits is not yet wired. The remote-tracking refs
436    /// advance unconditionally on a successful fetch; downstream
437    /// schema validation runs on read via the engine's existing
438    /// reload pipeline.
439    pub fn fetch(
440        &self,
441        mem: &str,
442        remote: &str,
443        refspecs: &[String],
444    ) -> Result<crate::ops::FetchOutcome, EngineError> {
445        let m = self.find_mount(mem)?;
446        match &m.mount.storage {
447            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
448                Err(EngineError::InvalidInput(format!(
449                    "mem '{mem}' is not git-backed — `memstead_fetch` requires a git-branch mount",
450                )))
451            }
452            MountStorage::GitBranch { gitdir, .. } => match self.git_branch_ops.as_ref() {
453                Some(hook) => (hook.fetch)(gitdir, remote, refspecs).map_err(|e| match e {
454                    BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
455                        EngineError::UnknownRemote(
456                            msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
457                        )
458                    }
459                    other => EngineError::Backend(other),
460                }),
461                None => Err(EngineError::Backend(BackendError::Other(
462                    "git-branch fetch hook not installed (full flavour not loaded)".to_string(),
463                ))),
464            },
465        }
466    }
467
468    /// Pull updates from `remote` into the named mem's branch.
469    /// Fetches into the remote-tracking ref, runs a pre-merge schema
470    /// validation pass against the prospective state, then
471    /// fast-forwards the local branch. Refuses with
472    /// `LOCAL_DIVERGENCE` for diverged local branches and with
473    /// `SCHEMA_VIOLATION_IN_FETCH` when the prospective state fails
474    /// schema validation — in both refusal cases the local branch
475    /// pointer is untouched (the underlying fetch has updated
476    /// `refs/remotes/*` but the engine has not promoted the new
477    /// state).
478    pub fn pull(
479        &mut self,
480        mem: &str,
481        remote: &str,
482    ) -> Result<crate::ops::PullOutcome, EngineError> {
483        let mount_idx = self
484            .mounts
485            .iter()
486            .position(|m| m.mount.mem == mem)
487            .ok_or_else(|| self.unknown_mem_error(mem))?;
488
489        // Run the fetch step alone first so we can validate the
490        // prospective state against the schema before letting the
491        // pull's fast-forward land. Errors map to the typed surface
492        // just like a standalone `memstead_fetch` call.
493        let gitdir = match &self.mounts[mount_idx].mount.storage {
494            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
495                return Err(EngineError::InvalidInput(format!(
496                    "mem '{mem}' is not git-backed — `memstead_pull` requires a git-branch mount",
497                )));
498            }
499            MountStorage::GitBranch { gitdir, .. } => gitdir.clone(),
500        };
501        let hook = self.git_branch_ops.ok_or_else(|| {
502            EngineError::Backend(BackendError::Other(
503                "git-branch pull hook not installed (full flavour not loaded)".to_string(),
504            ))
505        })?;
506        (hook.fetch)(&gitdir, remote, &[]).map_err(|e| match e {
507            BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
508                EngineError::UnknownRemote(
509                    msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
510                )
511            }
512            other => EngineError::Backend(other),
513        })?;
514
515        // Pre-merge schema validation. The remote-tracking ref now
516        // points at the fetched tip; we walk it, parse every `.md`
517        // blob against the mem's pinned schema, and refuse the
518        // pull if any parse fails. The local branch pointer is still
519        // unchanged at this point — the refusal is fully atomic.
520        let remote_ref = format!("refs/remotes/{remote}/{mem}");
521        self.validate_ref_against_schema(&hook, &gitdir, mem, &remote_ref)?;
522
523        // Run the underlying pull (re-runs the fetch via git CLI, but
524        // that's a no-op cache-wise and keeps the fast-forward logic
525        // co-located with the rest of the transport implementation).
526        let outcome = (hook.pull)(&gitdir, remote, mem).map_err(|e| match e {
527            BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
528                EngineError::UnknownRemote(
529                    msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
530                )
531            }
532            BackendError::Other(msg) if msg.starts_with("LOCAL_DIVERGENCE:") => {
533                let payload = msg.trim_start_matches("LOCAL_DIVERGENCE:");
534                let mut parts = payload.splitn(2, ':');
535                let v = parts.next().unwrap_or(mem).to_string();
536                let remote_ref = parts.next().unwrap_or("refs/remotes/?/?").to_string();
537                EngineError::LocalDivergence { mem: v, remote_ref }
538            }
539            other => EngineError::Backend(other),
540        })?;
541
542        // Rewind cached head + emit change event.
543        if outcome.previous_sha != outcome.new_sha {
544            if let Some(state) = self.mounts.get_mut(mount_idx) {
545                state.last_known_head = Some(outcome.new_sha.clone());
546            }
547            let event = crate::engine::events::MemChangedEvent {
548                mem: mem.to_string(),
549                head: outcome.new_sha.clone(),
550                previous: outcome.previous_sha.clone(),
551                n_commits: 1,
552            };
553            self.emit_mem_changed(&event);
554        }
555        Ok(outcome)
556    }
557
558    /// Push the named mem's branch to `remote`. Runs a pre-push
559    /// schema validation pass against the local branch tree; refuses
560    /// with `LOCAL_INVALID_STATE` when the local state fails schema
561    /// validation (the remote is not contacted in that case). Refuses
562    /// with `NON_FAST_FORWARD` when the push is not a fast-forward
563    /// and `force: false`; with `force: true` runs a
564    /// `--force-with-lease` push instead.
565    pub fn push(
566        &self,
567        mem: &str,
568        remote: &str,
569        force: bool,
570    ) -> Result<crate::ops::PushOutcome, EngineError> {
571        let m = self.find_mount(mem)?;
572        let gitdir = match &m.mount.storage {
573            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
574                return Err(EngineError::InvalidInput(format!(
575                    "mem '{mem}' is not git-backed — `memstead_push` requires a git-branch mount",
576                )));
577            }
578            MountStorage::GitBranch { gitdir, .. } => gitdir.clone(),
579        };
580        let hook = self.git_branch_ops.ok_or_else(|| {
581            EngineError::Backend(BackendError::Other(
582                "git-branch push hook not installed (full flavour not loaded)".to_string(),
583            ))
584        })?;
585
586        // Pre-push schema validation: walk the local branch tree, run
587        // the mem's pinned schema over every `.md` blob. Any parse
588        // failure refuses the push with `LOCAL_INVALID_STATE` — the
589        // remote is not contacted.
590        let local_ref = format!("refs/heads/{mem}");
591        if let Err(EngineError::SchemaViolationInFetch { violations, .. }) =
592            self.validate_ref_against_schema(&hook, &gitdir, mem, &local_ref)
593        {
594            return Err(EngineError::LocalInvalidState {
595                mem: mem.to_string(),
596                remote: remote.to_string(),
597                detail: format!(
598                    "{} violation(s) in local branch: {}",
599                    violations.len(),
600                    violations.join("; "),
601                ),
602            });
603        }
604
605        (hook.push)(&gitdir, remote, mem, force).map_err(|e| match e {
606            BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
607                EngineError::UnknownRemote(
608                    msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
609                )
610            }
611            BackendError::Other(msg) if msg.starts_with("NON_FAST_FORWARD:") => {
612                let payload = msg.trim_start_matches("NON_FAST_FORWARD:");
613                let mut parts = payload.splitn(2, ':');
614                let v = parts.next().unwrap_or(mem).to_string();
615                let r = parts.next().unwrap_or(remote).to_string();
616                EngineError::NonFastForward { mem: v, remote: r }
617            }
618            BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
619                EngineError::UnknownRef(msg.trim_start_matches("UNKNOWN_REF:").trim().to_string())
620            }
621            other => EngineError::Backend(other),
622        })
623    }
624
625    /// Configure (or re-point) a named remote on the workspace's
626    /// mem-repo, so `fetch` / `pull` / `push` have somewhere to go.
627    /// Upsert semantics — safe to re-run with a new URL. The mem-repo
628    /// is shared by every git-branch mount, so the op is
629    /// workspace-level: any git-branch mount locates it; refuses
630    /// `INVALID_INPUT` when the workspace has none.
631    pub fn remote_add(
632        &self,
633        name: &str,
634        url: &str,
635    ) -> Result<crate::ops::RemoteAddOutcome, EngineError> {
636        // Both values become git subprocess arguments — refuse shapes
637        // that would parse as flags.
638        if name.is_empty() || name.starts_with('-') || url.is_empty() || url.starts_with('-') {
639            return Err(EngineError::InvalidInput(format!(
640                "remote name and url must be non-empty and must not start with '-' \
641                 (got name '{name}', url '{url}')",
642            )));
643        }
644        let gitdir = self
645            .mounts
646            .iter()
647            .find_map(|m| match &m.mount.storage {
648                MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
649                _ => None,
650            })
651            .ok_or_else(|| {
652                EngineError::InvalidInput(
653                    "no git-branch mounts — `remote-add` requires a mem-repo workspace".to_string(),
654                )
655            })?;
656        let hook = self.git_branch_ops.ok_or_else(|| {
657            EngineError::Backend(BackendError::Other(
658                "git-branch remote_add hook not installed (full flavour not loaded)".to_string(),
659            ))
660        })?;
661        (hook.remote_add)(&gitdir, name, url).map_err(EngineError::Backend)
662    }
663
664    /// Pre-merge schema validation pass: walks every `.md` blob at
665    /// `ref_name` and runs `parse_entries` with the mem's pinned
666    /// schema. Returns `Ok(())` when the tree is schema-clean;
667    /// returns `EngineError::SchemaViolationInFetch` with the list of
668    /// per-entity violation messages otherwise. The validation is
669    /// strict on parse-time errors — any `(path, error)` pair from
670    /// `parse_entries` triggers a refusal.
671    ///
672    /// `ref_name` is the prospective state (a `refs/remotes/*` ref
673    /// for pull, `refs/heads/*` for push). The engine layer maps the
674    /// returned error into the surface code it needs
675    /// (`SCHEMA_VIOLATION_IN_FETCH` for pull, `LOCAL_INVALID_STATE`
676    /// for push).
677    fn validate_ref_against_schema(
678        &self,
679        hook: &crate::engine::GitBranchOps,
680        gitdir: &std::path::Path,
681        mem: &str,
682        ref_name: &str,
683    ) -> Result<(), EngineError> {
684        let schema = self
685            .schemas
686            .get(mem)
687            .ok_or_else(|| EngineError::SchemaNotFound {
688                mem: mem.to_string(),
689                pin: "<missing engine-side resolution>".to_string(),
690                // Internal invariant breach (an already-resolved schema
691                // absent from the per-mem map), not a source-resolution
692                // failure — no per-source diagnostics apply.
693                sources: Vec::new(),
694                install_hint: None,
695            })?
696            .clone();
697
698        let blobs = (hook.read_tree)(gitdir, ref_name).map_err(|e| match e {
699            BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
700                EngineError::UnknownRef(msg.trim_start_matches("UNKNOWN_REF:").trim().to_string())
701            }
702            other => EngineError::Backend(other),
703        })?;
704
705        let mut source_entries: Vec<crate::entity::source::SourceEntry> = Vec::new();
706        for (rel_path, content) in blobs {
707            source_entries.push(crate::entity::source::SourceEntry {
708                relative_path: rel_path.clone(),
709                source_path: std::path::PathBuf::from(rel_path),
710                content,
711            });
712        }
713
714        // First pass: permissive parse via the engine's loader so we
715        // can build Entity values for the strict validator. The
716        // loader silently absorbs frontmatter / title / section
717        // drift; the strict pass below is what catches it.
718        let load_result = crate::entity::loader::parse_entries(
719            source_entries.clone(),
720            Vec::new(),
721            mem,
722            schema.as_ref(),
723        );
724        let mut violations: Vec<String> = load_result
725            .errors
726            .iter()
727            .map(|(path, msg)| format!("{}: {msg}", path.display()))
728            .collect();
729
730        // Strict per-entity validator: enforces "looks like a mem
731        // entity" invariants (frontmatter shape, title presence,
732        // required sections, unknown sections, relationship syntax,
733        // wiki-link shape) that the permissive loader doesn't refuse.
734        // Re-runs against the same source bytes so unparseable
735        // frontmatter surfaces here even when the loader's tolerant
736        // path produces an Entity stub.
737        let entities_by_path: std::collections::HashMap<String, &crate::entity::Entity> =
738            load_result
739                .entities
740                .iter()
741                .map(|p| (p.entity.file_path.clone(), &p.entity))
742                .collect();
743        for source in &source_entries {
744            let Some(entity) = entities_by_path.get(&source.relative_path) else {
745                continue;
746            };
747            let type_def = match schema.get_type(&entity.entity_type) {
748                Some(t) => t,
749                None => {
750                    violations.push(format!(
751                        "{}: unknown entity_type '{}' in schema",
752                        source.relative_path, entity.entity_type,
753                    ));
754                    continue;
755                }
756            };
757            if let Err(e) = crate::validator::strict::validate_strict(
758                &source.content,
759                entity,
760                type_def.as_ref(),
761                &source.relative_path,
762            ) {
763                violations.push(format!("{}: {e}", source.relative_path));
764            }
765        }
766
767        if violations.is_empty() {
768            Ok(())
769        } else {
770            Err(EngineError::SchemaViolationInFetch {
771                mem: mem.to_string(),
772                ref_name: ref_name.to_string(),
773                violations,
774            })
775        }
776    }
777
778    /// Reset a mem's branch pointer to `target_sha`. The only
779    /// engine surface that moves a branch pointer over existing
780    /// commits — every other mutation appends. Refuses if any commit
781    /// that would be discarded by the reset is already reachable from
782    /// a `refs/remotes/*` ref (the engine's definition of "pushed").
783    ///
784    /// `target_sha` accepts anything `gix::rev_parse_single` admits:
785    /// a SHA, an abbreviated SHA, a branch name, a tag. The branch
786    /// itself (`refs/heads/<mem>`) must exist.
787    ///
788    /// Refusal codes:
789    /// - [`EngineError::UnknownMem`] (`UNKNOWN_MEM`)
790    /// - [`EngineError::UnknownRef`] (`UNKNOWN_REF`) — branch or
791    ///   target ref does not resolve.
792    /// - [`EngineError::PushedCommitsProtected`]
793    ///   (`PUSHED_COMMITS_PROTECTED`) — at least one discarded commit
794    ///   is pushed. The error carries the offending SHAs verbatim.
795    /// - [`EngineError::InvalidInput`] (`INVALID_INPUT`) — mem is
796    ///   folder / archive-backed (history rewriting only makes sense
797    ///   for git-branch mounts).
798    ///
799    /// Emits a [`crate::engine::events::MemChangedEvent`] on
800    /// success when the SHA actually changed; the reset's effect is
801    /// observable through the same change-event surface every commit
802    /// flows through. Engine's cached `last_known_head` for the
803    /// affected mount is rewound to the new SHA so the next drift
804    /// probe doesn't flag the reset as a sibling-writer surprise.
805    pub fn branch_reset(
806        &mut self,
807        mem: &str,
808        target_sha: &str,
809        expected_head: Option<&str>,
810    ) -> Result<crate::ops::BranchResetOutcome, EngineError> {
811        let mount_idx = self
812            .mounts
813            .iter()
814            .position(|m| m.mount.mem == mem)
815            .ok_or_else(|| self.unknown_mem_error(mem))?;
816
817        // History rewriting is a write — read-only and archive mounts
818        // refuse before any dispatch (parity with the mutation surface).
819        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
820            return Err(EngineError::ReadOnlyMount(mem.to_string()));
821        }
822
823        let outcome = match &self.mounts[mount_idx].mount.storage {
824            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
825                return Err(EngineError::InvalidInput(format!(
826                    "mem '{mem}' is not git-backed — `memstead_branch_reset` requires a git-branch mount",
827                )));
828            }
829            MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
830                Some(hook) => (hook.branch_reset)(gitdir, branch, target_sha, expected_head)
831                    .map_err(|e| match e {
832                        BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
833                            let raw = msg.trim_start_matches("UNKNOWN_REF:").trim().to_string();
834                            EngineError::UnknownRef(raw)
835                        }
836                        BackendError::Other(msg) if msg.starts_with("EXPECTED_HEAD_MISMATCH:") => {
837                            let current = msg
838                                .trim_start_matches("EXPECTED_HEAD_MISMATCH:")
839                                .trim()
840                                .to_string();
841                            EngineError::BranchResetHeadMoved {
842                                mem: mem.to_string(),
843                                expected: expected_head.unwrap_or_default().to_string(),
844                                current,
845                            }
846                        }
847                        BackendError::Other(msg)
848                            if msg.starts_with("PUSHED_COMMITS_PROTECTED:") =>
849                        {
850                            let payload =
851                                msg.trim_start_matches("PUSHED_COMMITS_PROTECTED:").trim();
852                            let pushed_shas = payload
853                                .split(',')
854                                .map(|s| s.trim().to_string())
855                                .filter(|s| !s.is_empty())
856                                .collect();
857                            EngineError::PushedCommitsProtected {
858                                mem: mem.to_string(),
859                                target_sha: target_sha.to_string(),
860                                pushed_shas,
861                            }
862                        }
863                        other => EngineError::Backend(other),
864                    })?,
865                None => {
866                    return Err(EngineError::Backend(BackendError::Other(
867                        "git-branch branch_reset hook not installed (full flavour not loaded)"
868                            .to_string(),
869                    )));
870                }
871            },
872        };
873
874        // Rewind the engine's cached HEAD so subsequent drift probes
875        // don't surface MEM_RELOADED for the reset we just made.
876        // Then emit a change event so subscribers see the transition
877        // (skipping the no-op case where previous == new).
878        if outcome.previous_sha != outcome.new_sha {
879            if let Some(state) = self.mounts.get_mut(mount_idx) {
880                state.last_known_head = Some(outcome.new_sha.clone());
881            }
882            let event = crate::engine::events::MemChangedEvent {
883                mem: mem.to_string(),
884                head: outcome.new_sha.clone(),
885                previous: outcome.previous_sha.clone(),
886                // n_commits stays at 1 for reset events. The wire
887                // shape is the same `MemChangedEvent` consumers
888                // already key on; semantics: "the head moved by this
889                // operation". Replay-aware consumers branch on the
890                // commit-vs-reset distinction by inspecting the
891                // produced commit (a reset's new head is an existing
892                // commit, not a freshly minted one).
893                n_commits: 1,
894            };
895            self.emit_mem_changed(&event);
896        }
897        Ok(outcome)
898    }
899
900    /// Cross-mem references that a reset of `mem` to `target_sha` would
901    /// strand: incoming edges from entities in *other* mems whose target
902    /// exists at the current head but would not exist at the target
903    /// commit — entities created after the target, or renamed to their
904    /// current id after it (the reset re-materialises the old id, so
905    /// references to the new id dangle either way).
906    ///
907    /// A read — computes against the live store and the commit history,
908    /// moves nothing. The human surface calls this fresh at
909    /// confirmation-dialog time and warns before `branch_reset`. Sorted
910    /// (from_id, to_id, rel_type) for stable rendering.
911    ///
912    /// Refusals mirror `changes_since`: `UnknownMem`, `InvalidCursor`
913    /// for an unresolvable `target_sha`, `InvalidInput` for
914    /// non-git-backed mounts.
915    pub fn branch_reset_stranded_refs(
916        &self,
917        mem: &str,
918        target_sha: &str,
919    ) -> Result<Vec<crate::ops::StrandedCrossMemRef>, EngineError> {
920        use crate::ops::ChangeEnvelope;
921
922        let report = self.changes_since(mem, target_sha, None)?;
923        let mut discarded: std::collections::HashSet<String> = std::collections::HashSet::new();
924        for change in &report.changes {
925            match change {
926                ChangeEnvelope::Added { id, .. } => {
927                    discarded.insert(id.to_string());
928                }
929                ChangeEnvelope::Renamed { to_id, .. } => {
930                    discarded.insert(to_id.to_string());
931                }
932                ChangeEnvelope::Updated { .. } | ChangeEnvelope::Removed { .. } => {}
933            }
934        }
935        if discarded.is_empty() {
936            return Ok(Vec::new());
937        }
938
939        let mut stranded: Vec<crate::ops::StrandedCrossMemRef> = self
940            .store
941            .all_entities()
942            .filter(|e| e.mem != mem)
943            .flat_map(|e| {
944                e.relationships
945                    .iter()
946                    .filter(|r| discarded.contains(&r.target.to_string()))
947                    .map(|r| crate::ops::StrandedCrossMemRef {
948                        from_id: e.id.to_string(),
949                        from_mem: e.mem.clone(),
950                        to_id: r.target.to_string(),
951                        rel_type: r.rel_type.clone(),
952                    })
953                    .collect::<Vec<_>>()
954            })
955            .collect();
956        stranded.sort_by(|a, b| {
957            (&a.from_id, &a.to_id, &a.rel_type).cmp(&(&b.from_id, &b.to_id, &b.rel_type))
958        });
959        Ok(stranded)
960    }
961
962    /// Two-ref structural diff. Produces a per-entity [`crate::ops::Diff`]
963    /// comparing the trees at `ref_a` and `ref_b` for the named
964    /// mem's storage. Folder and archive backends carry no git
965    /// refs and refuse via [`EngineError::InvalidInput`]; the
966    /// git-branch backend routes through [`GitBranchOps::diff`] when
967    /// the full flavour is loaded.
968    ///
969    /// `mem` selects the storage context (the gitdir, for
970    /// git-branch mounts). `ref_a` / `ref_b` are arbitrary refs the
971    /// underlying git layer accepts — branch names, commit SHAs, tag
972    /// names — so cross-mem diffs work via fully-qualified refs
973    /// (`refs/heads/<other-mem>`) without a separate API.
974    ///
975    /// Refusal codes:
976    /// - [`EngineError::UnknownMem`] (`UNKNOWN_MEM`) — no mount
977    ///   for `mem`.
978    /// - [`EngineError::UnknownRef`] (`UNKNOWN_REF`) — either ref
979    ///   does not resolve. Surfaces verbatim from the git layer's
980    ///   `rev_parse` refusal.
981    /// - [`EngineError::RenameSimilarityOutOfRange`] (`INVALID_INPUT`)
982    ///   — `config.rename_similarity` outside `[0.1, 1.0]`.
983    /// - [`EngineError::InvalidInput`] (`INVALID_INPUT`) — mem is
984    ///   folder or archive-backed (no refs to diff).
985    pub fn diff(
986        &self,
987        mem: &str,
988        ref_a: &str,
989        ref_b: &str,
990        config: Option<crate::ops::DiffConfig>,
991    ) -> Result<crate::ops::Diff, EngineError> {
992        let m = self.find_mount(mem)?;
993        let config = config.unwrap_or_default();
994
995        if config.rename_similarity < crate::ops::RENAME_SIMILARITY_MIN
996            || config.rename_similarity > crate::ops::RENAME_SIMILARITY_MAX
997        {
998            return Err(EngineError::RenameSimilarityOutOfRange {
999                requested: config.rename_similarity,
1000                allowed_min: crate::ops::RENAME_SIMILARITY_MIN,
1001                allowed_max: crate::ops::RENAME_SIMILARITY_MAX,
1002            });
1003        }
1004
1005        match &m.mount.storage {
1006            MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
1007                Err(EngineError::InvalidInput(format!(
1008                    "mem '{mem}' is not git-backed — `memstead_diff` requires a git-branch mount",
1009                )))
1010            }
1011            MountStorage::GitBranch { gitdir, .. } => match self.git_branch_ops.as_ref() {
1012                Some(hook) => {
1013                    (hook.diff)(gitdir, mem, ref_a, ref_b, &config).map_err(|e| match e {
1014                        // Map the standard backend-side "ref not found" shape into the
1015                        // typed engine-level refusal. The git-branch dispatcher uses
1016                        // `BackendError::Other` with a leading marker so the engine can
1017                        // recover the typed code without re-parsing the message.
1018                        BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
1019                            let raw = msg.trim_start_matches("UNKNOWN_REF:").trim().to_string();
1020                            EngineError::UnknownRef(raw)
1021                        }
1022                        other => EngineError::Backend(other),
1023                    })
1024                }
1025                None => Err(EngineError::Backend(BackendError::Other(
1026                    "git-branch diff hook not installed (full flavour not loaded)".to_string(),
1027                ))),
1028            },
1029        }
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use std::path::{Path, PathBuf};
1036
1037    use tempfile::TempDir;
1038
1039    use crate::backend::{BackendError, MemBackend};
1040    use crate::engine::test_helpers::*;
1041    use crate::engine::{DeleteEntityArgs, Engine, EngineError};
1042    use crate::entity::EntityId;
1043
1044    use crate::provenance::Provenance;
1045    use crate::storage::ArchiveBackend;
1046    use crate::vcs::CommitContext;
1047    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1048
1049    #[test]
1050    fn engine_diff_unknown_mem_returns_typed_error() {
1051        let tmp = TempDir::new().unwrap();
1052        let engine = build_demo_engine(&tmp);
1053        let err = engine.diff("nope", "a", "b", None).unwrap_err();
1054        assert!(matches!(err, EngineError::UnknownMem(v) if v == "nope"));
1055    }
1056
1057    #[test]
1058    fn engine_diff_folder_mount_refuses_with_invalid_input() {
1059        let tmp = TempDir::new().unwrap();
1060        let engine = build_demo_engine(&tmp);
1061        // Folder backend has no git refs — refuse cleanly via the
1062        // typed `INVALID_INPUT` code rather than collapsing through
1063        // the backend layer.
1064        let err = engine.diff("specs", "a", "b", None).unwrap_err();
1065        match err {
1066            EngineError::InvalidInput(msg) => {
1067                assert!(msg.contains("not git-backed"), "unexpected msg: {msg}");
1068            }
1069            other => panic!("expected InvalidInput, got {other:?}"),
1070        }
1071    }
1072
1073    #[test]
1074    fn engine_diff_rename_similarity_out_of_range_refuses() {
1075        let tmp = TempDir::new().unwrap();
1076        let engine = build_demo_engine(&tmp);
1077        let bad = crate::ops::DiffConfig {
1078            rename_similarity: 2.0,
1079            ..Default::default()
1080        };
1081        let err = engine.diff("specs", "a", "b", Some(bad)).unwrap_err();
1082        assert!(matches!(
1083            err,
1084            EngineError::RenameSimilarityOutOfRange { .. }
1085        ));
1086    }
1087
1088    #[test]
1089    fn engine_changes_since_archive_mount_returns_empty_report() {
1090        // Archive backends have no diff surface; the engine wrapper
1091        // produces an empty `ChangesReport` with the cursor echoed.
1092        let tmp = TempDir::new().unwrap();
1093        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
1094        let mount = archive_mount("ext", archive_path.clone());
1095        let engine = Engine::from_mounts(vec![(
1096            mount,
1097            Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1098        )])
1099        .unwrap();
1100        let report = engine.changes_since("ext", "abc", None).expect("known mem");
1101        assert_eq!(report.mem, "ext");
1102        assert_eq!(report.since, "abc");
1103        assert_eq!(report.head, "abc");
1104        assert!(report.changes.is_empty());
1105        assert!(report.warnings.is_empty());
1106    }
1107
1108    #[test]
1109    fn engine_changes_since_unknown_mem_returns_typed_error() {
1110        let tmp = TempDir::new().unwrap();
1111        let engine = build_demo_engine(&tmp);
1112        let err = engine
1113            .changes_since("does-not-exist", "abc", None)
1114            .unwrap_err();
1115        assert!(matches!(err, EngineError::UnknownMem(_)));
1116    }
1117
1118    #[test]
1119    fn engine_changes_since_refuses_rename_similarity_below_min() {
1120        let tmp = TempDir::new().unwrap();
1121        let engine = build_demo_engine(&tmp);
1122        // 0.05 is below RENAME_SIMILARITY_MIN (0.1); typed refusal,
1123        // not a silent clamp.
1124        let err = engine
1125            .changes_since("specs", "abc", Some(0.05))
1126            .expect_err("out-of-range refuses");
1127        match err {
1128            EngineError::RenameSimilarityOutOfRange {
1129                requested,
1130                allowed_min,
1131                allowed_max,
1132            } => {
1133                assert!((requested - 0.05).abs() < f32::EPSILON);
1134                assert!((allowed_min - crate::ops::RENAME_SIMILARITY_MIN).abs() < f32::EPSILON);
1135                assert!((allowed_max - crate::ops::RENAME_SIMILARITY_MAX).abs() < f32::EPSILON);
1136            }
1137            other => panic!("expected RenameSimilarityOutOfRange, got {other:?}"),
1138        }
1139    }
1140
1141    #[test]
1142    fn engine_changes_since_refuses_rename_similarity_above_max() {
1143        let tmp = TempDir::new().unwrap();
1144        let engine = build_demo_engine(&tmp);
1145        // 1.5 is above RENAME_SIMILARITY_MAX (1.0); typed refusal.
1146        let err = engine
1147            .changes_since("specs", "abc", Some(1.5))
1148            .expect_err("out-of-range refuses");
1149        match err {
1150            EngineError::RenameSimilarityOutOfRange { requested, .. } => {
1151                assert!((requested - 1.5).abs() < f32::EPSILON);
1152            }
1153            other => panic!("expected RenameSimilarityOutOfRange, got {other:?}"),
1154        }
1155    }
1156
1157    #[test]
1158    fn engine_changes_since_no_warning_when_rename_similarity_in_range() {
1159        let tmp = TempDir::new().unwrap();
1160        let engine = build_demo_engine(&tmp);
1161        // 0.5 is comfortably inside the valid range; no warning.
1162        let report = engine
1163            .changes_since("specs", "abc", Some(0.5))
1164            .expect("known mem");
1165        assert!(report.warnings.is_empty());
1166    }
1167
1168    #[test]
1169    fn engine_changes_since_no_warning_when_rename_similarity_omitted() {
1170        // Caller passes None → wrapper falls back to the default;
1171        // no clamping, no warning.
1172        let tmp = TempDir::new().unwrap();
1173        let engine = build_demo_engine(&tmp);
1174        let report = engine
1175            .changes_since("specs", "abc", None)
1176            .expect("known mem");
1177        assert!(report.warnings.is_empty());
1178    }
1179
1180    #[test]
1181    fn engine_changes_since_enriches_envelope_title_and_type_from_store() {
1182        // `build_demo_engine` creates three entities via the engine's
1183        // mutation pipeline, which appends Create events to the folder
1184        // backend's changelog. `Engine::changes_since` synthesises
1185        // BackendChanges from the changelog (id-only envelopes), then
1186        // enriches title / entity_type from the in-memory store.
1187        let tmp = TempDir::new().unwrap();
1188        let engine = build_demo_engine(&tmp);
1189        let report = engine
1190            .changes_since("specs", crate::ops::EMPTY_TREE_SHA, None)
1191            .expect("known mem");
1192
1193        // Three Create events → three Added envelopes, each enriched.
1194        assert_eq!(report.changes.len(), 3);
1195        for env in &report.changes {
1196            match env {
1197                crate::ops::ChangeEnvelope::Added {
1198                    id,
1199                    title,
1200                    entity_type,
1201                } => {
1202                    assert!(title.is_some(), "title enriched for {id}");
1203                    assert_eq!(entity_type.as_deref(), Some("spec"), "type for {id}");
1204                }
1205                other => panic!("expected Added envelope, got {other:?}"),
1206            }
1207        }
1208    }
1209
1210    #[test]
1211    fn engine_changes_since_removed_envelope_keeps_title_and_type_none() {
1212        // Create-then-delete net effect = Removed. Even though the
1213        // store may still know the entity, the engine wrapper
1214        // unconditionally strips title / entity_type on Removed.
1215        let tmp = TempDir::new().unwrap();
1216        let mut engine = build_demo_engine(&tmp);
1217        let (actor, client) = cli_actor();
1218        let id = EntityId::new("specs", "lonely-three");
1219        let hash = engine
1220            .get_entity(&id)
1221            .expect("seeded entity present")
1222            .content_hash
1223            .clone();
1224        engine
1225            .delete_entity(
1226                DeleteEntityArgs {
1227                    id: id.clone(),
1228                    expected_hash: Some(hash),
1229                },
1230                actor,
1231                Some(&client),
1232                None,
1233            )
1234            .unwrap();
1235        let report = engine
1236            .changes_since("specs", crate::ops::EMPTY_TREE_SHA, None)
1237            .unwrap();
1238        let removed = report
1239            .changes
1240            .iter()
1241            .find(|e| {
1242                matches!(e,
1243                crate::ops::ChangeEnvelope::Removed { id: rid, .. } if rid == &id)
1244            })
1245            .expect("removed envelope for lonely-three");
1246        match removed {
1247            crate::ops::ChangeEnvelope::Removed {
1248                title, entity_type, ..
1249            } => {
1250                assert!(title.is_none());
1251                assert!(entity_type.is_none());
1252            }
1253            other => panic!("expected Removed, got {other:?}"),
1254        }
1255    }
1256
1257    // ---- Engine::cross_mem_link_allowed ---------------------------
1258
1259    #[test]
1260    fn reload_if_stale_returns_empty_for_folder_only_engine() {
1261        // Folder mems now carry a changelog-derived drift cursor, so
1262        // this pins the QUIET case: no sibling wrote between probes,
1263        // so repeated checks stay warning-free (the first probe
1264        // captures the baseline silently, the second sees no advance).
1265        let tmp = TempDir::new().unwrap();
1266        let mut engine = build_demo_engine(&tmp);
1267        let warnings = engine.reload_if_stale(None);
1268        assert!(warnings.is_empty());
1269        let warnings = engine.reload_if_stale(Some("specs"));
1270        assert!(warnings.is_empty());
1271    }
1272
1273    #[test]
1274    fn reload_if_stale_short_circuits_for_unknown_mem_filter() {
1275        // Filtering by an unknown mem produces zero candidates;
1276        // the method returns an empty Vec without panicking.
1277        let tmp = TempDir::new().unwrap();
1278        let mut engine = build_demo_engine(&tmp);
1279        let warnings = engine.reload_if_stale(Some("does-not-exist"));
1280        assert!(warnings.is_empty());
1281    }
1282
1283    /// Test fixture: a `MemBackend` whose `current_head` and
1284    /// (read-side) entity surface are externally mutable so a test
1285    /// can simulate a sibling writer advancing the head between
1286    /// drift-check probes. Write methods are no-ops; the engine's
1287    /// drift-check path never invokes them.
1288    struct ManualHeadBackend {
1289        head: std::sync::Mutex<Option<String>>,
1290        entities: std::sync::Mutex<Vec<(PathBuf, Vec<u8>)>>,
1291    }
1292
1293    impl ManualHeadBackend {
1294        fn new(initial_head: Option<&str>) -> Self {
1295            Self {
1296                head: std::sync::Mutex::new(initial_head.map(String::from)),
1297                entities: std::sync::Mutex::new(Vec::new()),
1298            }
1299        }
1300
1301        fn set_head(&self, head: Option<&str>) {
1302            *self.head.lock().unwrap() = head.map(String::from);
1303        }
1304    }
1305
1306    impl MemBackend for ManualHeadBackend {
1307        fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1308            Ok(self
1309                .entities
1310                .lock()
1311                .unwrap()
1312                .iter()
1313                .map(|(p, _)| p.clone())
1314                .collect())
1315        }
1316        fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1317            Ok(self
1318                .entities
1319                .lock()
1320                .unwrap()
1321                .iter()
1322                .find(|(p, _)| p == rel)
1323                .map(|(_, b)| b.clone()))
1324        }
1325        fn write_entity(&self, _: &Path, _: &[u8]) -> Result<(), BackendError> {
1326            Ok(())
1327        }
1328        fn delete_entity(&self, _: &Path) -> Result<(), BackendError> {
1329            Ok(())
1330        }
1331        fn move_entity(&self, _: &Path, _: &Path) -> Result<(), BackendError> {
1332            Ok(())
1333        }
1334        fn commit(
1335            &self,
1336            _: &str,
1337            _: &CommitContext<'_>,
1338        ) -> Result<crate::storage::CommitId, BackendError> {
1339            Ok("synthetic".to_string())
1340        }
1341        fn append_provenance(&self, _: &Provenance) -> Result<(), BackendError> {
1342            Ok(())
1343        }
1344        fn read_provenance(&self, _: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1345            Ok(Vec::new())
1346        }
1347        fn current_head(&self) -> Result<Option<String>, BackendError> {
1348            Ok(self.head.lock().unwrap().clone())
1349        }
1350    }
1351
1352    #[test]
1353    fn reload_if_stale_emits_mem_reloaded_when_head_advances() {
1354        // Use an Arc<ManualHeadBackend> so the test retains a handle
1355        // for mutation after the engine has taken ownership of a
1356        // Box<dyn MemBackend> wrapper around it.
1357        struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1358        impl MemBackend for ArcBackend {
1359            fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1360                self.0.list_entities()
1361            }
1362            fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1363                self.0.read_entity(rel)
1364            }
1365            fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1366                self.0.write_entity(p, b)
1367            }
1368            fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1369                self.0.delete_entity(p)
1370            }
1371            fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1372                self.0.move_entity(f, t)
1373            }
1374            fn commit(
1375                &self,
1376                m: &str,
1377                c: &CommitContext<'_>,
1378            ) -> Result<crate::storage::CommitId, BackendError> {
1379                self.0.commit(m, c)
1380            }
1381            fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1382                self.0.append_provenance(r)
1383            }
1384            fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1385                self.0.read_provenance(c)
1386            }
1387            fn current_head(&self) -> Result<Option<String>, BackendError> {
1388                self.0.current_head()
1389            }
1390        }
1391
1392        let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1393        let backend = Box::new(ArcBackend(shared.clone()));
1394        let mount = Mount {
1395            mem: "specs".to_string(),
1396            schema: Some(pin("default")),
1397            storage: MountStorage::Folder {
1398                path: PathBuf::from("/dev/null"),
1399            },
1400            capability: MountCapability::Write,
1401            lifecycle: MountLifecycle::Eager,
1402            cross_linkable: true,
1403            migration_target: None,
1404        };
1405        let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1406
1407        // No drift on first probe — cached==new.
1408        let warnings = engine.reload_if_stale(Some("specs"));
1409        assert!(warnings.is_empty());
1410
1411        // Sibling writer advances the head.
1412        shared.set_head(Some("bbb"));
1413
1414        let warnings = engine.reload_if_stale(Some("specs"));
1415        assert_eq!(warnings.len(), 1);
1416        match &warnings[0] {
1417            crate::ops::WarningHint::MemReloaded {
1418                mem,
1419                old_head,
1420                new_head,
1421                ..
1422            } => {
1423                assert_eq!(mem, "specs");
1424                assert_eq!(old_head, "aaa");
1425                assert_eq!(new_head, "bbb");
1426            }
1427            other => panic!("expected MemReloaded, got {other:?}"),
1428        }
1429
1430        // Drift cleared — the engine's cached head now matches the
1431        // backend's current head; another probe is a no-op.
1432        let warnings = engine.reload_if_stale(Some("specs"));
1433        assert!(warnings.is_empty());
1434    }
1435
1436    #[test]
1437    fn mem_drifted_tracks_sibling_advance_until_reload() {
1438        // The read-only drift probe used by the macOS roster: it reports
1439        // `true` once a sibling writer advances the backend past the
1440        // engine's cached head, *without* itself reloading, and clears
1441        // after the engine re-reads.
1442        struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1443        impl MemBackend for ArcBackend {
1444            fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1445                self.0.list_entities()
1446            }
1447            fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1448                self.0.read_entity(rel)
1449            }
1450            fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1451                self.0.write_entity(p, b)
1452            }
1453            fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1454                self.0.delete_entity(p)
1455            }
1456            fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1457                self.0.move_entity(f, t)
1458            }
1459            fn commit(
1460                &self,
1461                m: &str,
1462                c: &CommitContext<'_>,
1463            ) -> Result<crate::storage::CommitId, BackendError> {
1464                self.0.commit(m, c)
1465            }
1466            fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1467                self.0.append_provenance(r)
1468            }
1469            fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1470                self.0.read_provenance(c)
1471            }
1472            fn current_head(&self) -> Result<Option<String>, BackendError> {
1473                self.0.current_head()
1474            }
1475        }
1476
1477        let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1478        let backend = Box::new(ArcBackend(shared.clone()));
1479        let mount = Mount {
1480            mem: "specs".to_string(),
1481            schema: Some(pin("default")),
1482            storage: MountStorage::Folder {
1483                path: PathBuf::from("/dev/null"),
1484            },
1485            capability: MountCapability::Write,
1486            lifecycle: MountLifecycle::Eager,
1487            cross_linkable: true,
1488            migration_target: None,
1489        };
1490        let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1491
1492        // Fresh boot: cached == live, no drift.
1493        assert!(!engine.mem_drifted("specs").unwrap());
1494
1495        // Sibling writer advances the head — drift is visible WITHOUT a reload.
1496        shared.set_head(Some("bbb"));
1497        assert!(engine.mem_drifted("specs").unwrap());
1498        // Probing did not reload — still drifted on a second read.
1499        assert!(engine.mem_drifted("specs").unwrap());
1500
1501        // Re-reading through the engine clears it.
1502        let _ = engine.reload_if_stale(Some("specs"));
1503        assert!(!engine.mem_drifted("specs").unwrap());
1504
1505        // Unknown mem errors rather than reporting a bogus `false`.
1506        assert!(matches!(
1507            engine.mem_drifted("nope"),
1508            Err(EngineError::UnknownMem(_))
1509        ));
1510    }
1511
1512    #[test]
1513    fn reload_one_mem_report_head_before_is_prior_cursor_and_advances() {
1514        // Regression for the reload→changes_since recipe. `head_before`
1515        // must report the engine's PRIOR cursor (the SHA it last knew),
1516        // not the post-drift on-disk tip — otherwise
1517        // `changes_since(since=head_before)` spans an empty range in
1518        // exactly the sibling-drift case the recipe targets. The reload
1519        // must also advance the cursor to the new tip so the next
1520        // staleness probe is a no-op rather than a spurious reload.
1521        struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1522        impl MemBackend for ArcBackend {
1523            fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1524                self.0.list_entities()
1525            }
1526            fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1527                self.0.read_entity(rel)
1528            }
1529            fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1530                self.0.write_entity(p, b)
1531            }
1532            fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1533                self.0.delete_entity(p)
1534            }
1535            fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1536                self.0.move_entity(f, t)
1537            }
1538            fn commit(
1539                &self,
1540                m: &str,
1541                c: &CommitContext<'_>,
1542            ) -> Result<crate::storage::CommitId, BackendError> {
1543                self.0.commit(m, c)
1544            }
1545            fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1546                self.0.append_provenance(r)
1547            }
1548            fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1549                self.0.read_provenance(c)
1550            }
1551            fn current_head(&self) -> Result<Option<String>, BackendError> {
1552                self.0.current_head()
1553            }
1554        }
1555
1556        let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1557        let backend = Box::new(ArcBackend(shared.clone()));
1558        let mount = Mount {
1559            mem: "specs".to_string(),
1560            schema: Some(pin("default")),
1561            storage: MountStorage::Folder {
1562                path: PathBuf::from("/dev/null"),
1563            },
1564            capability: MountCapability::Write,
1565            lifecycle: MountLifecycle::Eager,
1566            cross_linkable: true,
1567            migration_target: None,
1568        };
1569        let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1570
1571        // Sibling writer advances the head past the engine's cursor.
1572        shared.set_head(Some("bbb"));
1573
1574        let report = engine.reload_one_mem_report("specs").unwrap();
1575        // head_before is the prior cursor "aaa", not the drifted tip.
1576        assert_eq!(report.head_before, "aaa");
1577        assert_eq!(report.head_after, "bbb");
1578
1579        // Cursor advanced to "bbb": a follow-up staleness probe is a
1580        // no-op, not a spurious MEM_RELOADED.
1581        let warnings = engine.reload_if_stale(Some("specs"));
1582        assert!(
1583            warnings.is_empty(),
1584            "cursor should have advanced to bbb, got {warnings:?}"
1585        );
1586    }
1587
1588    #[test]
1589    fn reload_if_stale_fires_every_call_no_throttle() {
1590        // Two back-to-back probes with the head advancing between
1591        // them: the second must reload and warn. There is no throttle
1592        // window — the ref check is the correctness floor.
1593        let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1594        struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1595        impl MemBackend for ArcBackend {
1596            fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1597                self.0.list_entities()
1598            }
1599            fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1600                self.0.read_entity(rel)
1601            }
1602            fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1603                self.0.write_entity(p, b)
1604            }
1605            fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1606                self.0.delete_entity(p)
1607            }
1608            fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1609                self.0.move_entity(f, t)
1610            }
1611            fn commit(
1612                &self,
1613                m: &str,
1614                c: &CommitContext<'_>,
1615            ) -> Result<crate::storage::CommitId, BackendError> {
1616                self.0.commit(m, c)
1617            }
1618            fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1619                self.0.append_provenance(r)
1620            }
1621            fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1622                self.0.read_provenance(c)
1623            }
1624            fn current_head(&self) -> Result<Option<String>, BackendError> {
1625                self.0.current_head()
1626            }
1627        }
1628
1629        let backend = Box::new(ArcBackend(shared.clone()));
1630        let mount = Mount {
1631            mem: "specs".to_string(),
1632            schema: Some(pin("default")),
1633            storage: MountStorage::Folder {
1634                path: PathBuf::from("/dev/null"),
1635            },
1636            capability: MountCapability::Write,
1637            lifecycle: MountLifecycle::Eager,
1638            cross_linkable: true,
1639            migration_target: None,
1640        };
1641        let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1642
1643        // First probe observes cached==new; no warning.
1644        let warnings = engine.reload_if_stale(Some("specs"));
1645        assert!(warnings.is_empty());
1646
1647        // Sibling advances head — the very next probe reloads and
1648        // warns, with no throttle window to mask it.
1649        shared.set_head(Some("bbb"));
1650        let warnings = engine.reload_if_stale(Some("specs"));
1651        assert_eq!(
1652            warnings.len(),
1653            1,
1654            "no throttle window — the moved ref reloads on the next probe"
1655        );
1656    }
1657}