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