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