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