Skip to main content

khive_vcs/
sync.rs

1//! NDJSON-to-SQLite sync library boundary.
2//!
3//! Rebuilds the SQLite database from `.khive/kg/entities.ndjson` and `edges.ndjson`.
4//! Builds atomically into a `.tmp` file then renames. Also supports remote archive
5//! fetch with SHA-256 pin verification via [`run_sync_remote`].
6
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::str::FromStr;
11
12use anyhow::{anyhow, bail, Context, Result};
13use chrono::Utc;
14use khive_runtime::portability::{ExportedEdge, ExportedEntity, KgArchive};
15use khive_runtime::{entity_fts_document, KhiveRuntime, RuntimeConfig};
16use khive_storage::types::Edge;
17use khive_storage::LinkId;
18use khive_types::{EdgeRelation, EntityKind};
19use serde::{Deserialize, Serialize};
20use uuid::Uuid;
21
22use crate::error::VcsError;
23use crate::hash::snapshot_id_for_archive;
24use crate::types::SnapshotId;
25
26/// Per-record entity shape in NDJSON sources.
27#[derive(Debug, Serialize, Deserialize)]
28struct NdjsonEntity {
29    id: Uuid,
30    kind: String,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    entity_type: Option<String>,
33    name: String,
34    #[serde(default)]
35    description: Option<String>,
36    #[serde(default)]
37    properties: Option<serde_json::Value>,
38    #[serde(default)]
39    tags: Vec<String>,
40    #[serde(default)]
41    created_at: Option<String>,
42    #[serde(default)]
43    updated_at: Option<String>,
44}
45
46/// Per-record edge shape in NDJSON sources.
47#[derive(Debug, Serialize, Deserialize)]
48struct NdjsonEdge {
49    edge_id: Uuid,
50    source: Uuid,
51    target: Uuid,
52    relation: String,
53    #[serde(default = "default_weight")]
54    weight: f64,
55    // properties: accepted but not yet persisted to the storage-layer Edge
56    // struct. Parsed here so existing NDJSON files round-trip without warning.
57    #[serde(default)]
58    // REASON: Accepted for NDJSON round-trip compatibility; not yet persisted to the Edge struct.
59    #[allow(dead_code)]
60    properties: Option<serde_json::Value>,
61    #[serde(default)]
62    created_at: Option<String>,
63    #[serde(default)]
64    // REASON: Accepted for NDJSON round-trip compatibility; edge updated_at is derived from created_at.
65    #[allow(dead_code)]
66    updated_at: Option<String>,
67}
68
69fn default_weight() -> f64 {
70    1.0
71}
72
73/// Parse an ISO-8601 timestamp string into microseconds since epoch.
74/// Returns `now` if the string is `None` or unparseable.
75fn parse_ts_micros(s: Option<&str>) -> i64 {
76    s.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
77        .map(|dt| dt.timestamp_micros())
78        .unwrap_or_else(|| chrono::Utc::now().timestamp_micros())
79}
80
81/// Summary of a completed sync run.
82#[derive(Debug, Serialize)]
83pub struct SyncReport {
84    pub entities: usize,
85    pub edges: usize,
86    pub db_path: String,
87}
88
89// ── F201: Remote archive fetch ────────────────────────────────────────────────
90
91/// A validated remote cache name: a single path segment safe to join under
92/// `.khive/kg/remotes/` without escaping that directory.
93///
94/// Construct via [`RemoteName::parse`]. There is no public way to build a
95/// `RemoteName` that fails validation, so a `RemoteConfig` can never carry an
96/// unsafe name into [`run_sync_remote`] (VCS-AUD-002).
97#[derive(Clone, PartialEq, Eq, Hash)]
98pub struct RemoteName(String);
99
100/// Renders exactly like a plain `String`'s `Debug` (quoted), so existing
101/// `{:?}`-formatted error messages that embedded `remote.name` keep the same
102/// shape after the `String` -> `RemoteName` migration.
103impl std::fmt::Debug for RemoteName {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        std::fmt::Debug::fmt(&self.0, f)
106    }
107}
108
109impl RemoteName {
110    /// Validate `raw` as a safe single-path-segment remote name.
111    ///
112    /// Rejects: empty strings, `.`, `..`, any name containing `/` or `\`, and
113    /// any character outside `[A-Za-z0-9._-]`. Because path separators are
114    /// rejected outright, an absolute path (Unix `/root`, Windows `C:\root`)
115    /// can never pass — `:` is also outside the allowed character set.
116    pub fn parse(raw: impl Into<String>) -> Result<Self, VcsError> {
117        let raw = raw.into();
118        let valid = !raw.is_empty()
119            && raw != "."
120            && raw != ".."
121            && raw
122                .chars()
123                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
124            && !raw.contains('/')
125            && !raw.contains('\\');
126        if !valid {
127            return Err(VcsError::InvalidRemoteName(raw));
128        }
129        Ok(Self(raw))
130    }
131
132    /// The validated name as a `&str`, safe to join onto a cache directory path.
133    pub fn as_str(&self) -> &str {
134        &self.0
135    }
136}
137
138impl std::fmt::Display for RemoteName {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.write_str(&self.0)
141    }
142}
143
144/// Configuration for a remote KG archive (maps to one entry in `schema.yaml`
145/// `remotes:` list).
146#[derive(Debug, Clone)]
147pub struct RemoteConfig {
148    /// Validated name for this remote (used in error messages and cache
149    /// directory paths). Constructed via [`RemoteName::parse`], so a
150    /// `RemoteConfig` can never carry a path-traversal or absolute-path name.
151    pub name: RemoteName,
152    /// Git remote URL (e.g. `https://github.com/org/kg-data.git`).
153    pub url: String,
154    /// Git ref to check out (branch or tag, e.g. `main`).
155    pub git_ref: String,
156    /// Namespace to assign to imported records.
157    pub namespace: String,
158    /// Optional SHA-256 content-hash pin. When present, a mismatch between the
159    /// fetched archive hash and this value aborts the sync (fail-closed).
160    pub pin: Option<SnapshotId>,
161}
162
163/// Summary of a completed remote sync run (F201).
164#[derive(Debug, Serialize)]
165pub struct RemoteSyncReport {
166    pub entities: usize,
167    pub edges: usize,
168    /// Path to the populated cache directory (`.khive/kg/remotes/<name>/`).
169    pub cache_dir: String,
170    /// Path to the written `meta.json` file.
171    pub meta_path: String,
172    /// Canonical SHA-256 content hash of the fetched archive (`sha256:<hex>`).
173    pub content_hash: String,
174    /// `true` when `repin` was requested — the caller should write
175    /// `content_hash` back to `schema.yaml` as the new `pin` value.
176    pub repinned: bool,
177}
178
179/// Metadata written to `.khive/kg/remotes/<name>/meta.json`.
180#[derive(Debug, Serialize)]
181struct MetaJson {
182    /// ISO-8601 timestamp of when the fetch completed.
183    fetched_at: String,
184    /// Git ref that was resolved.
185    git_ref: String,
186    /// Git commit SHA resolved from `git_ref` at fetch time.
187    commit_sha: String,
188    /// Canonical content hash of the fetched archive.
189    content_hash: String,
190}
191
192/// Fetch a remote KG archive, verify SHA-256, populate `.khive/kg/remotes/`, write `meta.json`.
193/// Fail-closed on hash mismatch; use `repin=true` to update the pin.
194pub async fn run_sync_remote(
195    repo_root: &Path,
196    remote: &RemoteConfig,
197    repin: bool,
198) -> Result<RemoteSyncReport> {
199    // ── 1. Create staging directory ──────────────────────────────────────────
200    let state_dir = repo_root.join(".khive/state/remote-staging");
201    std::fs::create_dir_all(&state_dir)
202        .with_context(|| format!("creating staging dir {}", state_dir.display()))?;
203    let staging = tempfile::TempDir::new_in(&state_dir).context("creating staging temp dir")?;
204    let staging_path = staging.path().to_path_buf();
205
206    // ── 2. Git clone (sparse, depth=1) ───────────────────────────────────────
207    let entities_ndjson: Vec<NdjsonEntity>;
208    let edges_ndjson: Vec<NdjsonEdge>;
209    let commit_sha: String;
210
211    {
212        // Clone only the objects needed — no blobs, just tree metadata, then
213        // sparse-checkout the two NDJSON files we need.
214        let clone_out = Command::new("git")
215            .args([
216                "clone",
217                "--depth=1",
218                "--filter=blob:none",
219                "--no-checkout",
220                "--branch",
221                &remote.git_ref,
222            ])
223            .arg(&remote.url)
224            .arg(&staging_path)
225            .output()
226            .context("running git clone")?;
227
228        if !clone_out.status.success() {
229            let stderr = String::from_utf8_lossy(&clone_out.stderr);
230            let safe = redact_git_stderr(stderr.trim());
231            return Err(anyhow!(
232                "git clone failed for remote {:?}: {}",
233                remote.name,
234                safe
235            ));
236        }
237
238        // Resolve commit SHA from HEAD.
239        let rev_out = Command::new("git")
240            .args(["rev-parse", "HEAD"])
241            .current_dir(&staging_path)
242            .output()
243            .context("running git rev-parse HEAD")?;
244        commit_sha = String::from_utf8_lossy(&rev_out.stdout).trim().to_string();
245
246        // Sparse checkout: enable and limit to the two NDJSON files.
247        run_git_in(&staging_path, &["sparse-checkout", "init", "--cone"])
248            .context("git sparse-checkout init")?;
249        run_git_in(
250            &staging_path,
251            &[
252                "sparse-checkout",
253                "set",
254                ".khive/kg/entities.ndjson",
255                ".khive/kg/edges.ndjson",
256            ],
257        )
258        .context("git sparse-checkout set")?;
259        run_git_in(&staging_path, &["checkout"]).context("git checkout")?;
260
261        // Parse the staged NDJSON files.
262        let entities_path = staging_path.join(".khive/kg/entities.ndjson");
263        let edges_path = staging_path.join(".khive/kg/edges.ndjson");
264
265        entities_ndjson = read_entities(&entities_path)
266            .with_context(|| format!("reading staged {}", entities_path.display()))?;
267        edges_ndjson = read_edges(&edges_path)
268            .with_context(|| format!("reading staged {}", edges_path.display()))?;
269    }
270    // `staging` tempdir is still alive here — we drop it after moving files.
271
272    // ── 3. Build KgArchive and compute canonical hash ─────────────────────────
273    // build_kg_archive is fallible: an invalid relation causes it to return an
274    // error here, before any cache file is written (fail-closed).
275    let archive = build_kg_archive(&remote.namespace, &entities_ndjson, &edges_ndjson)
276        .with_context(|| format!("validating archive for remote {:?}", remote.name))?;
277    let actual_hash = snapshot_id_for_archive(&archive)
278        .map_err(|e| anyhow!("hashing archive for remote {:?}: {}", remote.name, e))?;
279
280    // ── 4. Pin verification (fail-closed) ────────────────────────────────────
281    if let Some(expected) = &remote.pin {
282        if !repin && actual_hash != *expected {
283            return Err(anyhow!(VcsError::HashMismatch {
284                expected: expected.clone(),
285                actual: actual_hash.clone(),
286            })
287            .context(format!(
288                "remote {:?}: hash mismatch — use `--repin` to accept the new content \
289                 after independently verifying it (actual hash: {})",
290                remote.name,
291                actual_hash.as_str()
292            )));
293        }
294    }
295
296    // ── 5. Build meta and atomically publish to cache ─────────────────────────
297    let meta = MetaJson {
298        fetched_at: Utc::now().to_rfc3339(),
299        git_ref: remote.git_ref.clone(),
300        commit_sha,
301        content_hash: actual_hash.as_str().to_string(),
302    };
303
304    let remotes_root = repo_root.join(".khive/kg/remotes");
305    let cache_dir = remotes_root.join(remote.name.as_str());
306    let published = publish_remote_cache(
307        &remotes_root,
308        remote.name.as_str(),
309        &entities_ndjson,
310        &edges_ndjson,
311        &meta,
312        #[cfg(test)]
313        None,
314    )
315    .with_context(|| format!("publishing cache for remote {:?}", remote.name))?;
316    debug_assert_eq!(published, cache_dir);
317
318    // staging tempdir (the git clone) is dropped here, cleaning up the clone.
319    drop(staging);
320
321    Ok(RemoteSyncReport {
322        entities: entities_ndjson.len(),
323        edges: edges_ndjson.len(),
324        cache_dir: cache_dir.to_string_lossy().into_owned(),
325        meta_path: cache_dir.join("meta.json").to_string_lossy().into_owned(),
326        content_hash: actual_hash.as_str().to_string(),
327        repinned: repin,
328    })
329}
330
331/// Injection points for #475 failure-injection tests: simulate a crash at each
332/// publish step so tests can assert readers never observe a mixed cache state.
333#[cfg(test)]
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335enum PublishFailAt {
336    AfterEntities,
337    AfterEdges,
338    AfterMeta,
339    BeforeSwap,
340}
341
342/// Publish a complete `{entities.ndjson, edges.ndjson, meta.json}` triple to
343/// `<remotes_root>/<name>/` as a single atomic unit.
344///
345/// Builds a complete staging directory (a sibling of the cache directory,
346/// under `remotes_root`) containing all three files, then switches visibility
347/// with one directory-rename swap ([`atomic_replace_dir`]). A crash or error
348/// at any point before the swap leaves the existing cache untouched; a crash
349/// or error during the swap either leaves the old cache in place or completes
350/// to the new cache — a reader never observes a mix of old and new files.
351fn publish_remote_cache(
352    remotes_root: &Path,
353    name: &str,
354    entities: &[NdjsonEntity],
355    edges: &[NdjsonEdge],
356    meta: &MetaJson,
357    #[cfg(test)] fail_at: Option<PublishFailAt>,
358) -> Result<PathBuf> {
359    std::fs::create_dir_all(remotes_root)
360        .with_context(|| format!("creating {}", remotes_root.display()))?;
361    let staging = tempfile::TempDir::new_in(remotes_root).context("creating staging dir")?;
362
363    write_sorted_entities(&staging.path().join("entities.ndjson"), entities)
364        .context("writing staged entities.ndjson")?;
365    #[cfg(test)]
366    if fail_at == Some(PublishFailAt::AfterEntities) {
367        anyhow::bail!("injected failure after staged entities write");
368    }
369
370    write_sorted_edges(&staging.path().join("edges.ndjson"), edges)
371        .context("writing staged edges.ndjson")?;
372    #[cfg(test)]
373    if fail_at == Some(PublishFailAt::AfterEdges) {
374        anyhow::bail!("injected failure after staged edges write");
375    }
376
377    let meta_json = serde_json::to_string_pretty(meta).context("serializing meta.json")?;
378    std::fs::write(staging.path().join("meta.json"), meta_json.as_bytes())
379        .context("writing staged meta.json")?;
380    fsync_dir_best_effort(staging.path());
381    #[cfg(test)]
382    if fail_at == Some(PublishFailAt::AfterMeta) {
383        anyhow::bail!("injected failure after staged meta write, before swap");
384    }
385
386    let cache_dir = remotes_root.join(name);
387    #[cfg(test)]
388    if fail_at == Some(PublishFailAt::BeforeSwap) {
389        anyhow::bail!("injected failure before swap");
390    }
391    atomic_replace_dir(staging.path(), &cache_dir)?;
392    fsync_dir_best_effort(remotes_root);
393
394    Ok(cache_dir)
395}
396
397/// Atomically replace `target_dir` with `new_dir` (a complete, ready-to-serve
398/// directory). If `target_dir` does not exist yet, `new_dir` is simply renamed
399/// into place. If `target_dir` already exists, the existing directory is first
400/// renamed to a sibling backup path, then `new_dir` is renamed into
401/// `target_dir`'s place; the backup is removed only after the swap succeeds.
402/// If the second rename fails, the backup is restored so the old cache is
403/// never lost. Both renames are single filesystem rename(2) calls, each of
404/// which is atomic — at every instant `target_dir` resolves to either the
405/// complete old directory, is briefly absent, or resolves to the complete new
406/// directory; it never contains a mix of old and new files.
407fn atomic_replace_dir(new_dir: &Path, target_dir: &Path) -> Result<()> {
408    if !target_dir.exists() {
409        std::fs::rename(new_dir, target_dir).with_context(|| {
410            format!("renaming {} -> {}", new_dir.display(), target_dir.display())
411        })?;
412        return Ok(());
413    }
414
415    let backup = target_dir.with_file_name(format!(
416        "{}.replaced-{}",
417        target_dir
418            .file_name()
419            .and_then(|n| n.to_str())
420            .unwrap_or("cache"),
421        std::process::id()
422    ));
423    let _ = std::fs::remove_dir_all(&backup);
424
425    std::fs::rename(target_dir, &backup).with_context(|| {
426        format!(
427            "backing up existing cache {} -> {}",
428            target_dir.display(),
429            backup.display()
430        )
431    })?;
432
433    match std::fs::rename(new_dir, target_dir) {
434        Ok(()) => {
435            let _ = std::fs::remove_dir_all(&backup);
436            Ok(())
437        }
438        Err(e) => {
439            // Restore the old cache so a failed swap never leaves the target missing.
440            let _ = std::fs::rename(&backup, target_dir);
441            Err(e).with_context(|| {
442                format!(
443                    "renaming {} -> {} (old cache restored)",
444                    new_dir.display(),
445                    target_dir.display()
446                )
447            })
448        }
449    }
450}
451
452/// Best-effort `fsync` of a directory's entries, where the platform supports
453/// opening a directory as a file handle (Unix). Errors are ignored: this is a
454/// durability improvement, not a correctness requirement for the atomicity
455/// guarantee above (which relies on rename(2) semantics, not fsync).
456fn fsync_dir_best_effort(dir: &Path) {
457    if let Ok(f) = std::fs::File::open(dir) {
458        let _ = f.sync_all();
459    }
460}
461
462/// Redact URLs and embedded credentials (`user:token@host` HTTPS forms, and
463/// scp-style `user@host:path` remotes) from git stderr before it reaches a
464/// caller-visible error — ADR-037 §157 prohibits leaking remote URLs. See
465/// `docs/api/sync.md` for the exact matched forms.
466fn redact_git_stderr(raw: &str) -> String {
467    let mut out = String::with_capacity(raw.len());
468    let bytes = raw.as_bytes();
469    let mut i = 0;
470    while i < bytes.len() {
471        if bytes[i..].starts_with(b"://") {
472            // Walk back over the scheme characters already written.
473            let scheme_start = {
474                let mut s = i;
475                while s > 0 && {
476                    let b = bytes[s - 1];
477                    b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.'
478                } {
479                    s -= 1;
480                }
481                s
482            };
483            let already_appended = i - scheme_start;
484            out.truncate(out.len() - already_appended);
485            // Advance past "://" and consume until the next whitespace or EOL.
486            let rest_start = i + 3;
487            let url_end = bytes[rest_start..]
488                .iter()
489                .position(|&b| b.is_ascii_whitespace())
490                .map(|p| rest_start + p)
491                .unwrap_or(bytes.len());
492            out.push_str("<url-redacted>");
493            i = url_end;
494        } else if is_scp_remote_start(bytes, i) {
495            // scp-style remote: `word@host:path`.  Walk back to the start of the
496            // `word` part (already written into `out`), then consume forward to
497            // the end of the token (next whitespace or end of input).
498            let token_start = scan_back_word(bytes, i);
499            let already_appended = i - token_start;
500            out.truncate(out.len() - already_appended);
501            // Consume `@host:path` (the token continues until whitespace/EOL).
502            let token_end = bytes[i..]
503                .iter()
504                .position(|&b| b.is_ascii_whitespace())
505                .map(|p| i + p)
506                .unwrap_or(bytes.len());
507            out.push_str("<url-redacted>");
508            i = token_end;
509        } else {
510            out.push(bytes[i] as char);
511            i += 1;
512        }
513    }
514    out
515}
516
517/// `true` when position `i` is the `@` of an scp-style remote (`word@host:path`,
518/// distinguished from `word@host: message text` by requiring a non-whitespace,
519/// non-`:` character right after the colon). See `docs/api/sync.md`.
520fn is_scp_remote_start(bytes: &[u8], i: usize) -> bool {
521    if bytes[i] != b'@' {
522        return false;
523    }
524    // There must be at least one non-whitespace, non-@ character before `@`.
525    if i == 0 || bytes[i - 1].is_ascii_whitespace() {
526        return false;
527    }
528    // After `@` there must be content and eventually a `:non-space` sequence.
529    let after_at = &bytes[i + 1..];
530    // Find `:` in the host portion (before any whitespace).
531    let colon_pos = after_at
532        .iter()
533        .position(|&b| b == b':' || b.is_ascii_whitespace());
534    match colon_pos {
535        Some(p) if after_at[p] == b':' => {
536            // Colon found; make sure the character after it is not whitespace
537            // and not another colon (IPv6 / port disambiguation).
538            let next = p + 1;
539            if next >= after_at.len() {
540                return false;
541            }
542            let ch = after_at[next];
543            !ch.is_ascii_whitespace() && ch != b':'
544        }
545        _ => false,
546    }
547}
548
549/// Walk backwards from `i` to find the start of the current word (sequence of
550/// non-whitespace, non-quote characters).
551fn scan_back_word(bytes: &[u8], i: usize) -> usize {
552    let mut s = i;
553    while s > 0 {
554        let b = bytes[s - 1];
555        if b.is_ascii_whitespace() || b == b'\'' || b == b'"' {
556            break;
557        }
558        s -= 1;
559    }
560    s
561}
562
563/// Run a git command inside `dir`, returning an error if it fails.
564fn run_git_in(dir: &Path, args: &[&str]) -> Result<()> {
565    let out = Command::new("git")
566        .args(args)
567        .current_dir(dir)
568        .output()
569        .with_context(|| format!("running git {}", args.join(" ")))?;
570    if !out.status.success() {
571        let stderr = String::from_utf8_lossy(&out.stderr);
572        let safe = redact_git_stderr(stderr.trim());
573        return Err(anyhow!("git {} failed: {}", args.join(" "), safe));
574    }
575    Ok(())
576}
577
578/// Convert the NDJSON record slices into a [`KgArchive`] for hashing.
579///
580/// Returns an error if any edge carries an unrecognised relation string, so
581/// that invalid edges are rejected *before* the hash is computed and before
582/// any cache or database write occurs (fail-closed).
583fn build_kg_archive(
584    namespace: &str,
585    entities: &[NdjsonEntity],
586    edges: &[NdjsonEdge],
587) -> Result<KgArchive> {
588    let now = Utc::now();
589    let exported_entities: Vec<ExportedEntity> = entities
590        .iter()
591        .map(|e| ExportedEntity {
592            id: e.id,
593            kind: e.kind.clone(),
594            entity_type: e.entity_type.clone(),
595            name: e.name.clone(),
596            description: e.description.clone(),
597            properties: e.properties.clone(),
598            tags: e.tags.clone(),
599            created_at: e
600                .created_at
601                .as_deref()
602                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
603                .map(|dt| dt.with_timezone(&Utc))
604                .unwrap_or(now),
605            updated_at: e
606                .updated_at
607                .as_deref()
608                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
609                .map(|dt| dt.with_timezone(&Utc))
610                .unwrap_or(now),
611        })
612        .collect();
613
614    let mut exported_edges: Vec<ExportedEdge> = Vec::with_capacity(edges.len());
615    for e in edges {
616        let relation: EdgeRelation = e
617            .relation
618            .parse()
619            .map_err(|err| anyhow!("invalid edge relation {:?}: {}", e.relation, err))?;
620        exported_edges.push(ExportedEdge {
621            edge_id: e.edge_id,
622            source: e.source,
623            target: e.target,
624            relation,
625            weight: e.weight,
626        });
627    }
628
629    Ok(KgArchive {
630        format: "khive-kg".into(),
631        version: "0.1".into(),
632        namespace: namespace.to_string(),
633        exported_at: now,
634        entities: exported_entities,
635        edges: exported_edges,
636    })
637}
638
639/// Write entities to a file as sorted NDJSON (one JSON object per line).
640///
641/// Entities are sorted by UUID string (case-insensitive ascending) to match
642/// the canonical sort order used by `snapshot_id_for_archive`.
643fn write_sorted_entities(path: &Path, records: &[NdjsonEntity]) -> Result<()> {
644    let mut sorted: Vec<&NdjsonEntity> = records.iter().collect();
645    sorted.sort_by(|a, b| {
646        a.id.to_string()
647            .to_ascii_lowercase()
648            .cmp(&b.id.to_string().to_ascii_lowercase())
649    });
650    let mut lines = Vec::with_capacity(sorted.len());
651    for r in sorted {
652        let line = serde_json::to_string(r).context("serializing entity")?;
653        lines.push(line);
654    }
655    std::fs::write(path, lines.join("\n")).context("writing entities file")?;
656    Ok(())
657}
658
659/// Write edges to a file as sorted NDJSON (one JSON object per line).
660///
661/// Edges are sorted by (source, target, relation) to match the canonical sort
662/// order used by `snapshot_id_for_archive`.
663fn write_sorted_edges(path: &Path, records: &[NdjsonEdge]) -> Result<()> {
664    let mut sorted: Vec<&NdjsonEdge> = records.iter().collect();
665    sorted.sort_by(|a, b| {
666        let ak = (
667            a.source.to_string(),
668            a.target.to_string(),
669            a.relation.clone(),
670        );
671        let bk = (
672            b.source.to_string(),
673            b.target.to_string(),
674            b.relation.clone(),
675        );
676        ak.cmp(&bk)
677    });
678    let mut lines = Vec::with_capacity(sorted.len());
679    for r in sorted {
680        let line = serde_json::to_string(r).context("serializing edge")?;
681        lines.push(line);
682    }
683    std::fs::write(path, lines.join("\n")).context("writing edges file")?;
684    Ok(())
685}
686
687/// Full ADR-020 structural validation of parsed NDJSON records (#476).
688///
689/// Checks entity kind validity, entity/edge timestamp validity, entity/edge
690/// sort order (matching `write_sorted_entities`/`write_sorted_edges`), duplicate
691/// entity ids, duplicate edge ids, duplicate semantic edge triples
692/// (source, target, relation), dangling edge endpoints, and edge relation/weight
693/// validity. Called before any temp DB is created so a violation here leaves
694/// the existing target DB completely untouched.
695fn validate_ndjson_records(entities: &[NdjsonEntity], edges: &[NdjsonEdge]) -> Result<()> {
696    let mut entity_ids: HashSet<Uuid> = HashSet::with_capacity(entities.len());
697    let mut prev_entity_key: Option<String> = None;
698    for (i, e) in entities.iter().enumerate() {
699        EntityKind::from_str(&e.kind)
700            .map_err(|_| anyhow!("entity {i} ({}): unknown kind {:?}", e.id, e.kind))?;
701
702        if !entity_ids.insert(e.id) {
703            bail!("entity {i}: duplicate entity id {}", e.id);
704        }
705
706        for (field, value) in [("created_at", &e.created_at), ("updated_at", &e.updated_at)] {
707            if let Some(s) = value {
708                chrono::DateTime::parse_from_rfc3339(s)
709                    .with_context(|| format!("entity {i} ({}): invalid {field} {s:?}", e.id))?;
710            }
711        }
712
713        let key = e.id.to_string().to_ascii_lowercase();
714        if let Some(prev) = &prev_entity_key {
715            if key < *prev {
716                bail!(
717                    "entities.ndjson is not sorted: entity {i} ({}) is out of order",
718                    e.id
719                );
720            }
721        }
722        prev_entity_key = Some(key);
723    }
724
725    let mut edge_ids: HashSet<Uuid> = HashSet::with_capacity(edges.len());
726    let mut triples: HashSet<(Uuid, Uuid, EdgeRelation)> = HashSet::with_capacity(edges.len());
727    let mut prev_edge_key: Option<(String, String, String)> = None;
728    for (i, r) in edges.iter().enumerate() {
729        let relation = r.relation.parse::<EdgeRelation>().with_context(|| {
730            format!(
731                "invalid edge relation {:?} at record {} — sync aborted before any DB write",
732                r.relation,
733                i + 1
734            )
735        })?;
736
737        if !r.weight.is_finite() || !(0.0..=1.0).contains(&r.weight) {
738            bail!(
739                "edge {i} ({}): weight {} out of range; must be finite and in [0.0, 1.0]",
740                r.edge_id,
741                r.weight
742            );
743        }
744
745        if !edge_ids.insert(r.edge_id) {
746            bail!("edge {i}: duplicate edge id {}", r.edge_id);
747        }
748
749        if !triples.insert((r.source, r.target, relation)) {
750            bail!(
751                "edge {i} ({}): duplicate edge triple (source={}, target={}, relation={:?})",
752                r.edge_id,
753                r.source,
754                r.target,
755                relation
756            );
757        }
758
759        if !entity_ids.contains(&r.source) {
760            bail!(
761                "edge {i} ({}): dangling source {} — no matching entity",
762                r.edge_id,
763                r.source
764            );
765        }
766        if !entity_ids.contains(&r.target) {
767            bail!(
768                "edge {i} ({}): dangling target {} — no matching entity",
769                r.edge_id,
770                r.target
771            );
772        }
773
774        for (field, value) in [("created_at", &r.created_at), ("updated_at", &r.updated_at)] {
775            if let Some(s) = value {
776                chrono::DateTime::parse_from_rfc3339(s)
777                    .with_context(|| format!("edge {i} ({}): invalid {field} {s:?}", r.edge_id))?;
778            }
779        }
780
781        let key = (
782            r.source.to_string(),
783            r.target.to_string(),
784            r.relation.clone(),
785        );
786        if let Some(prev) = &prev_edge_key {
787            if key < *prev {
788                bail!(
789                    "edges.ndjson is not sorted: edge {i} ({}) is out of order",
790                    r.edge_id
791                );
792            }
793        }
794        prev_edge_key = Some(key);
795    }
796
797    Ok(())
798}
799
800/// Rebuild `db_path` from `.khive/kg/{entities,edges}.ndjson` under `repo_root`.
801///
802/// The operation is atomic: the database is built in a `.tmp` sibling file and
803/// renamed over `db_path` only on success. A crash or error leaves the previous
804/// `db_path` intact.
805///
806/// `namespace` is applied to all imported records.
807///
808/// Returns a [`SyncReport`] on success, or an error if NDJSON parsing or SQLite
809/// upserts fail.
810pub async fn run_sync(repo_root: &Path, db_path: &Path, namespace: &str) -> Result<SyncReport> {
811    let entities_path = repo_root.join(".khive/kg/entities.ndjson");
812    let edges_path = repo_root.join(".khive/kg/edges.ndjson");
813
814    let entity_records = read_entities(&entities_path)
815        .with_context(|| format!("reading {}", entities_path.display()))?;
816    let edge_records =
817        read_edges(&edges_path).with_context(|| format!("reading {}", edges_path.display()))?;
818
819    // ── Validate-first gate (#476) ────────────────────────────────────────────
820    // Run the full ADR-020 structural validation before creating the temp DB,
821    // so any violation leaves the existing DB completely untouched.
822    validate_ndjson_records(&entity_records, &edge_records).context(
823        "validating ADR-020 KG NDJSON before DB rebuild — sync aborted before any DB write",
824    )?;
825
826    let tmp_path = with_extension_suffix(db_path, ".tmp");
827    let _ = std::fs::remove_file(&tmp_path);
828
829    // Build the runtime against the tmp file. Vector embedding is disabled
830    // because sync runs without an embedding model loaded — vectors are
831    // computed lazily on access via the MCP server if needed.
832    let ns = khive_types::Namespace::parse(namespace)
833        .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
834    let config = RuntimeConfig {
835        db_path: Some(tmp_path.clone()),
836        default_namespace: ns,
837        embedding_model: None,
838        ..RuntimeConfig::default()
839    };
840    let runtime = KhiveRuntime::new(config)
841        .with_context(|| format!("building runtime for {}", tmp_path.display()))?;
842
843    let entity_count = upsert_entities(&runtime, namespace, entity_records).await?;
844    let edge_count = upsert_edges(&runtime, namespace, edge_records).await?;
845
846    // Checkpoint the WAL so all committed writes land in the main DB file.
847    // Without this, `rename(tmp, target)` moves only the main file and leaves
848    // the -wal alongside it; opening `target` later would see only the data
849    // through the last auto-checkpoint (every 4000 pages). For small graphs no
850    // auto-checkpoint fires, so the data would silently disappear.
851    checkpoint_wal(&runtime)
852        .await
853        .context("checkpoint WAL before rename")?;
854
855    // Drop the runtime so SQLite releases its file handles before rename.
856    drop(runtime);
857
858    if let Some(parent) = db_path.parent() {
859        std::fs::create_dir_all(parent)
860            .with_context(|| format!("creating {}", parent.display()))?;
861    }
862    std::fs::rename(&tmp_path, db_path)
863        .with_context(|| format!("renaming {} -> {}", tmp_path.display(), db_path.display()))?;
864
865    Ok(SyncReport {
866        entities: entity_count,
867        edges: edge_count,
868        db_path: db_path.to_string_lossy().into_owned(),
869    })
870}
871
872fn with_extension_suffix(p: &Path, suffix: &str) -> PathBuf {
873    let mut s = p.as_os_str().to_owned();
874    s.push(suffix);
875    PathBuf::from(s)
876}
877
878fn read_entities(path: &Path) -> Result<Vec<NdjsonEntity>> {
879    if !path.exists() {
880        return Ok(Vec::new());
881    }
882    let text = std::fs::read_to_string(path)?;
883    let mut out = Vec::new();
884    for (i, line) in text.lines().enumerate() {
885        let trimmed = line.trim();
886        if trimmed.is_empty() {
887            continue;
888        }
889        let e: NdjsonEntity = serde_json::from_str(trimmed)
890            .with_context(|| format!("parsing entity at line {}", i + 1))?;
891        out.push(e);
892    }
893    Ok(out)
894}
895
896fn read_edges(path: &Path) -> Result<Vec<NdjsonEdge>> {
897    if !path.exists() {
898        return Ok(Vec::new());
899    }
900    let text = std::fs::read_to_string(path)?;
901    let mut out = Vec::new();
902    for (i, line) in text.lines().enumerate() {
903        let trimmed = line.trim();
904        if trimmed.is_empty() {
905            continue;
906        }
907        let e: NdjsonEdge = serde_json::from_str(trimmed)
908            .with_context(|| format!("parsing edge at line {}", i + 1))?;
909        out.push(e);
910    }
911    Ok(out)
912}
913
914async fn checkpoint_wal(runtime: &KhiveRuntime) -> Result<()> {
915    let mut writer = runtime.backend().sql().writer().await?;
916    // Top-level statement — a checkpoint cannot complete while a transaction
917    // is open on the same connection. `execute_script` wraps its statement in
918    // the WriterTask's per-request BEGIN IMMEDIATE under KHIVE_WRITE_QUEUE=1,
919    // which would make this call silently no-op the checkpoint (the subsequent
920    // rename above would then lose data — see the comment at the call site).
921    writer
922        .execute_script_top_level("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
923        .await?;
924    Ok(())
925}
926
927// Number of rows committed per SQLite transaction during bulk sync.
928// 10_000 keeps WAL growth per chunk below ~40 MiB at an average 4 KiB entity,
929// while amortising transaction overhead across many rows.
930// In test builds the value is reduced so chunk-boundary tests run without
931// generating tens of thousands of synthetic rows.
932#[cfg(not(test))]
933const SYNC_CHUNK_SIZE: usize = 10_000;
934#[cfg(test)]
935const SYNC_CHUNK_SIZE: usize = 5;
936
937async fn upsert_entities(
938    runtime: &KhiveRuntime,
939    namespace: &str,
940    records: Vec<NdjsonEntity>,
941) -> Result<usize> {
942    let ns = khive_types::Namespace::parse(namespace)
943        .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
944    let token = runtime.authorize(ns)?;
945    let store = runtime.entities(&token).context("opening entity store")?;
946    let text = runtime.text(&token).context("opening text store")?;
947
948    // Convert and write SYNC_CHUNK_SIZE records at a time so that peak
949    // converted-buffer memory is O(SYNC_CHUNK_SIZE), not O(records.len()).
950    // Field mapping is identical to the previous per-row loop so that
951    // sync, create, update, merge, and reindex produce identical shapes.
952    let mut count = 0usize;
953    for chunk in records.chunks(SYNC_CHUNK_SIZE) {
954        let mut entities_chunk = Vec::with_capacity(chunk.len());
955        let mut docs_chunk = Vec::with_capacity(chunk.len());
956        for r in chunk {
957            let created_at = parse_ts_micros(r.created_at.as_deref());
958            let updated_at = parse_ts_micros(r.updated_at.as_deref());
959            let entity = khive_storage::entity::Entity {
960                id: r.id,
961                namespace: namespace.to_string(),
962                kind: r.kind.clone(),
963                entity_type: r.entity_type.clone(),
964                name: r.name.clone(),
965                description: r.description.clone(),
966                properties: r.properties.clone(),
967                tags: r.tags.clone(),
968                created_at,
969                updated_at,
970                deleted_at: None,
971                merge_event_id: None,
972                merged_into: None,
973                content_ref: None,
974            };
975            // Use the canonical FTS document constructor so sync, create, update,
976            // merge, and reindex all produce identical document shapes.
977            let fts_doc = entity_fts_document(&entity);
978            entities_chunk.push(entity);
979            docs_chunk.push(fts_doc);
980        }
981
982        // Entity rows — one BEGIN IMMEDIATE / COMMIT per chunk.
983        let summary = store
984            .upsert_entities(entities_chunk)
985            .await
986            .context("batch upsert entities")?;
987        if summary.failed > 0 {
988            return Err(anyhow!(
989                "entity write: {}/{} rows failed (first: {})",
990                summary.failed,
991                summary.attempted,
992                summary.first_error
993            ));
994        }
995        count += summary.affected as usize;
996
997        // FTS docs — one BEGIN IMMEDIATE / COMMIT per chunk.
998        // Vectors are intentionally skipped: they are local-only derived state
999        // and will be computed by `kkernel kg embed` when needed.
1000        let summary = text
1001            .upsert_documents(docs_chunk)
1002            .await
1003            .context("batch FTS upsert")?;
1004        if summary.failed > 0 {
1005            return Err(anyhow!(
1006                "FTS write: {}/{} docs failed in chunk",
1007                summary.failed,
1008                summary.attempted
1009            ));
1010        }
1011    }
1012    Ok(count)
1013}
1014
1015async fn upsert_edges(
1016    runtime: &KhiveRuntime,
1017    namespace: &str,
1018    records: Vec<NdjsonEdge>,
1019) -> Result<usize> {
1020    let ns = khive_types::Namespace::parse(namespace)
1021        .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
1022    let token = runtime.authorize(ns)?;
1023    let graph = runtime.graph(&token).context("opening graph store")?;
1024
1025    // Convert and write SYNC_CHUNK_SIZE edges at a time so that peak
1026    // converted-buffer memory is O(SYNC_CHUNK_SIZE), not O(records.len()).
1027    // Edge relation validation already ran in run_sync before the tmp DB was
1028    // created, so parse() here should always succeed.
1029    // upsert_edges rolls back the entire chunk on the first storage error
1030    // and returns Err, which propagates via ? without advancing count.
1031    let mut count = 0usize;
1032    for chunk in records.chunks(SYNC_CHUNK_SIZE) {
1033        let mut edge_chunk = Vec::with_capacity(chunk.len());
1034        for r in chunk {
1035            let relation: EdgeRelation = r
1036                .relation
1037                .parse()
1038                .map_err(|e| anyhow!("invalid relation {:?}: {}", r.relation, e))?;
1039            let created_at =
1040                chrono::DateTime::from_timestamp_micros(parse_ts_micros(r.created_at.as_deref()))
1041                    .unwrap_or_else(chrono::Utc::now);
1042            let edge = Edge {
1043                id: LinkId::from(r.edge_id),
1044                namespace: namespace.to_string(),
1045                source_id: r.source,
1046                target_id: r.target,
1047                relation,
1048                weight: r.weight,
1049                created_at,
1050                updated_at: created_at,
1051                deleted_at: None,
1052                metadata: None,
1053                target_backend: None,
1054            };
1055            edge_chunk.push(edge);
1056        }
1057        let summary = graph
1058            .upsert_edges(edge_chunk)
1059            .await
1060            .context("batch upsert edges")?;
1061        count += summary.affected as usize;
1062    }
1063    Ok(count)
1064}
1065
1066// ── Tests ─────────────────────────────────────────────────────────────────────
1067
1068// INLINE TEST JUSTIFICATION: Tests access private helpers (build_kg_archive,
1069// read_entities, read_edges, compute_pin) that cannot be exposed in crate-level
1070// tests/ without promoting them to pub(crate), which would widen the internal API.
1071// Production code above this line is ~625 LOC (under the 700-line gate).
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use tempfile::TempDir;
1076
1077    // ── F201 test helpers ─────────────────────────────────────────────────────
1078
1079    /// Create a minimal git repository under `dir` with the given NDJSON content
1080    /// inside `.khive/kg/`. Returns the URL-style path suitable for `git clone`.
1081    fn make_git_remote(dir: &Path, entities_ndjson: &str, edges_ndjson: &str) -> String {
1082        let kg_dir = dir.join(".khive/kg");
1083        std::fs::create_dir_all(&kg_dir).unwrap();
1084        std::fs::write(kg_dir.join("entities.ndjson"), entities_ndjson).unwrap();
1085        std::fs::write(kg_dir.join("edges.ndjson"), edges_ndjson).unwrap();
1086
1087        // Initialise git repo with a single commit on `main`.
1088        run_git(dir, &["init", "-b", "main"]);
1089        run_git(dir, &["config", "user.email", "test@example.com"]);
1090        run_git(dir, &["config", "user.name", "Test"]);
1091        run_git(dir, &["add", "-A"]);
1092        run_git(dir, &["commit", "-m", "init"]);
1093
1094        dir.to_string_lossy().into_owned()
1095    }
1096
1097    fn run_git(dir: &Path, args: &[&str]) {
1098        // Hermetic: user-level core.hooksPath (e.g. the machine-wide JSON/JSONL
1099        // data-leak guard) must not run against fixture commits in temp repos.
1100        let status = Command::new("git")
1101            .args(["-c", "core.hooksPath=/dev/null"])
1102            .args(args)
1103            .current_dir(dir)
1104            .status()
1105            .unwrap_or_else(|e| panic!("git {} failed to spawn: {e}", args.join(" ")));
1106        assert!(
1107            status.success(),
1108            "git {} exited with {}",
1109            args.join(" "),
1110            status
1111        );
1112    }
1113
1114    /// Compute the canonical `SnapshotId` for entity/edge NDJSON strings without
1115    /// touching the filesystem, so we can build expected pins from in-memory data.
1116    fn compute_pin(entities_ndjson: &str, edges_ndjson: &str, namespace: &str) -> SnapshotId {
1117        let tmp = TempDir::new().unwrap();
1118        let kg = tmp.path().join(".khive/kg");
1119        std::fs::create_dir_all(&kg).unwrap();
1120        std::fs::write(kg.join("entities.ndjson"), entities_ndjson).unwrap();
1121        std::fs::write(kg.join("edges.ndjson"), edges_ndjson).unwrap();
1122
1123        let entities = read_entities(&kg.join("entities.ndjson")).unwrap();
1124        let edges = read_edges(&kg.join("edges.ndjson")).unwrap();
1125        let archive = build_kg_archive(namespace, &entities, &edges).unwrap();
1126        snapshot_id_for_archive(&archive).unwrap()
1127    }
1128
1129    // ── test_run_sync_local_path_unchanged_behavior ───────────────────────────
1130
1131    fn write_repo(dir: &Path, entities_ndjson: &str, edges_ndjson: &str) {
1132        let kg_dir = dir.join(".khive/kg");
1133        std::fs::create_dir_all(&kg_dir).unwrap();
1134        std::fs::write(kg_dir.join("entities.ndjson"), entities_ndjson).unwrap();
1135        std::fs::write(kg_dir.join("edges.ndjson"), edges_ndjson).unwrap();
1136    }
1137
1138    #[tokio::test]
1139    async fn sync_empty_ndjson_produces_real_sqlite_file() {
1140        let tmp = TempDir::new().unwrap();
1141        let repo = tmp.path();
1142        let db_path = repo.join(".khive/state/working.db");
1143        write_repo(repo, "", "");
1144
1145        let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1146        assert_eq!(report.entities, 0);
1147        assert_eq!(report.edges, 0);
1148
1149        let bytes = std::fs::read(&db_path).unwrap();
1150        assert!(!bytes.is_empty(), "DB file must be non-empty after sync");
1151        assert!(
1152            bytes.starts_with(b"SQLite format 3\0"),
1153            "DB file must start with SQLite magic header, got {:?}",
1154            &bytes[..bytes.len().min(20)]
1155        );
1156    }
1157
1158    #[tokio::test]
1159    async fn sync_imports_entities_and_edges_into_real_db() {
1160        let tmp = TempDir::new().unwrap();
1161        let repo = tmp.path();
1162        let db_path = repo.join(".khive/state/working.db");
1163
1164        let id_a = "11111111-1111-1111-1111-111111111111";
1165        let id_b = "22222222-2222-2222-2222-222222222222";
1166        let edge_id = "33333333-3333-3333-3333-333333333333";
1167
1168        let line_a = format!(
1169            r#"{{"id":"{id_a}","kind":"concept","name":"Alpha","properties":{{}},"tags":[]}}"#
1170        );
1171        let line_b = format!(
1172            r#"{{"id":"{id_b}","kind":"concept","name":"Beta","properties":{{}},"tags":[]}}"#
1173        );
1174        let entities = format!("{line_a}\n{line_b}\n");
1175        let edges = format!(
1176            r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":1.0,"properties":{{}}}}"#
1177        );
1178        write_repo(repo, &entities, &edges);
1179
1180        let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1181        assert_eq!(report.entities, 2);
1182        assert_eq!(report.edges, 1);
1183
1184        let ns = khive_types::Namespace::parse("test-ns").unwrap();
1185        let config = RuntimeConfig {
1186            db_path: Some(db_path.clone()),
1187            default_namespace: ns.clone(),
1188            embedding_model: None,
1189            ..RuntimeConfig::default()
1190        };
1191        let rt = KhiveRuntime::new(config).unwrap();
1192        let token = rt.authorize(ns).unwrap();
1193        let alpha = rt
1194            .entities(&token)
1195            .unwrap()
1196            .get_entity(id_a.parse().unwrap())
1197            .await
1198            .unwrap()
1199            .expect("entity Alpha must be retrievable after sync");
1200        assert_eq!(alpha.name, "Alpha");
1201        assert_eq!(alpha.kind, "concept");
1202    }
1203
1204    /// Chunk-boundary round-trip across `SYNC_CHUNK_SIZE`. See
1205    /// `docs/api/sync.md#local-sync--run_sync`.
1206    #[tokio::test]
1207    async fn sync_chunk_boundary_round_trip() {
1208        const N: usize = 11; // > SYNC_CHUNK_SIZE (5 in test mode)
1209
1210        let tmp = TempDir::new().unwrap();
1211        let repo = tmp.path();
1212        let db_path = repo.join(".khive/state/working.db");
1213
1214        // Generate N synthetic entity lines with predictable UUIDs.
1215        let ids: Vec<uuid::Uuid> = (0..N).map(|_| uuid::Uuid::new_v4()).collect();
1216        // #476 requires entities.ndjson to be in canonical ascending-by-id
1217        // order (matching `write_sorted_entities`), so sort the lines below
1218        // even though `ids` itself stays in generation order (used to build
1219        // the wrap-around edges and to spot-check the last-generated entity).
1220        let mut entity_lines: Vec<(uuid::Uuid, String)> = ids
1221            .iter()
1222            .enumerate()
1223            .map(|(i, id)| {
1224                (
1225                    *id,
1226                    format!(
1227                        r#"{{"id":"{id}","kind":"concept","name":"SyntheticEntity{i}","description":"Synthetic test entity {i} for chunk-boundary coverage","properties":{{}},"tags":["bench","synthetic"]}}"#
1228                    ),
1229                )
1230            })
1231            .collect();
1232        entity_lines.sort_by(|a, b| {
1233            a.0.to_string()
1234                .to_ascii_lowercase()
1235                .cmp(&b.0.to_string().to_ascii_lowercase())
1236        });
1237        let entities_ndjson = entity_lines
1238            .into_iter()
1239            .map(|(_, line)| line)
1240            .collect::<Vec<_>>()
1241            .join("\n");
1242
1243        // Generate N synthetic edges: each entity points at the next one via
1244        // "extends". The last entity wraps back to the first. #476 requires
1245        // edges.ndjson sorted by (source, target, relation), matching
1246        // `write_sorted_edges`.
1247        let mut edge_lines: Vec<((String, String, String), String)> = ids
1248            .iter()
1249            .enumerate()
1250            .map(|(i, &src)| {
1251                let tgt = ids[(i + 1) % N];
1252                let eid = uuid::Uuid::new_v4();
1253                let key = (src.to_string(), tgt.to_string(), "extends".to_string());
1254                let line = format!(
1255                    r#"{{"edge_id":"{eid}","source":"{src}","target":"{tgt}","relation":"extends","weight":0.9,"properties":{{}}}}"#
1256                );
1257                (key, line)
1258            })
1259            .collect();
1260        edge_lines.sort_by(|a, b| a.0.cmp(&b.0));
1261        let edges_ndjson = edge_lines
1262            .into_iter()
1263            .map(|(_, line)| line)
1264            .collect::<Vec<_>>()
1265            .join("\n");
1266
1267        write_repo(repo, &entities_ndjson, &edges_ndjson);
1268
1269        let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1270        assert_eq!(report.entities, N, "all {N} entities must be written");
1271        assert_eq!(report.edges, N, "all {N} edges must be written");
1272
1273        // Spot-check: the last entity (in the final partial chunk) must be readable.
1274        let last_id = *ids.last().unwrap();
1275        let ns = khive_types::Namespace::parse("test-ns").unwrap();
1276        let config = RuntimeConfig {
1277            db_path: Some(db_path.clone()),
1278            default_namespace: ns.clone(),
1279            embedding_model: None,
1280            ..RuntimeConfig::default()
1281        };
1282        let rt = KhiveRuntime::new(config).unwrap();
1283        let token = rt.authorize(ns).unwrap();
1284        let last_entity = rt
1285            .entities(&token)
1286            .unwrap()
1287            .get_entity(last_id)
1288            .await
1289            .unwrap()
1290            .expect("last entity (final chunk) must be readable after sync");
1291        assert_eq!(
1292            last_entity.name,
1293            format!("SyntheticEntity{}", N - 1),
1294            "last entity name must match"
1295        );
1296    }
1297
1298    /// Error-abort: a parse failure before any DB write leaves the existing DB intact.
1299    /// This covers the failure-semantics contract: sync aborts on the first error
1300    /// and the target DB is never replaced by a partial result.
1301    #[tokio::test]
1302    async fn sync_aborts_on_invalid_ndjson_before_db_write() {
1303        let tmp = TempDir::new().unwrap();
1304        let repo = tmp.path();
1305        let db_path = repo.join(".khive/state/working.db");
1306        std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
1307        std::fs::write(&db_path, b"ORIGINAL").unwrap();
1308
1309        // Mix valid entities with one invalid line to trigger a parse error
1310        // before any DB write.
1311        let id_a = uuid::Uuid::new_v4();
1312        let good_line = format!(
1313            r#"{{"id":"{id_a}","kind":"concept","name":"Good","properties":{{}},"tags":[]}}"#
1314        );
1315        let bad_ndjson = format!("{good_line}\nnot-valid-json\n");
1316        write_repo(repo, &bad_ndjson, "");
1317
1318        let err = run_sync(repo, &db_path, "test-ns")
1319            .await
1320            .expect_err("sync must fail on invalid NDJSON");
1321        assert!(
1322            err.to_string().contains("parsing entity")
1323                || err.chain().any(|e| e.to_string().contains("expected")),
1324            "error must describe the parse failure, got: {err}"
1325        );
1326
1327        // DB must be untouched (atomic rename guarantee).
1328        let after = std::fs::read(&db_path).unwrap();
1329        assert_eq!(
1330            after, b"ORIGINAL",
1331            "failed sync must not replace existing DB"
1332        );
1333    }
1334
1335    #[tokio::test]
1336    async fn sync_is_atomic_via_tmp_rename() {
1337        let tmp = TempDir::new().unwrap();
1338        let repo = tmp.path();
1339        let db_path = repo.join(".khive/state/working.db");
1340        std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
1341        std::fs::write(&db_path, b"SENTINEL").unwrap();
1342
1343        write_repo(repo, "not json\n", "");
1344        let err = run_sync(repo, &db_path, "test-ns").await.unwrap_err();
1345        assert!(
1346            err.to_string().to_lowercase().contains("parsing entity")
1347                || err.chain().any(|e| e.to_string().contains("expected")),
1348            "expected parse error, got: {err}"
1349        );
1350
1351        let after = std::fs::read(&db_path).unwrap();
1352        assert_eq!(
1353            after, b"SENTINEL",
1354            "atomic guarantee: failed sync must not replace existing DB"
1355        );
1356    }
1357
1358    #[tokio::test]
1359    async fn sync_missing_ndjson_files_succeeds_with_zero_counts() {
1360        let tmp = TempDir::new().unwrap();
1361        let repo = tmp.path();
1362        let db_path = repo.join(".khive/state/working.db");
1363
1364        let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1365        assert_eq!(report.entities, 0);
1366        assert_eq!(report.edges, 0);
1367    }
1368
1369    // ── #476: validate-first gate — one test per violation type ─────────────────
1370    //
1371    // Each test writes a sentinel DB file, runs `run_sync` against NDJSON that
1372    // violates exactly one structural rule, asserts `run_sync` returns `Err`,
1373    // and asserts the sentinel DB file is byte-unchanged: the violation must be
1374    // caught before the temp DB is even created, let alone renamed over the
1375    // target.
1376
1377    async fn assert_sync_rejected_before_db_write(
1378        repo: &Path,
1379        db_path: &Path,
1380        entities_ndjson: &str,
1381        edges_ndjson: &str,
1382        expected_substr: &str,
1383    ) {
1384        std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
1385        std::fs::write(db_path, b"SENTINEL").unwrap();
1386        write_repo(repo, entities_ndjson, edges_ndjson);
1387
1388        let err = run_sync(repo, db_path, "test-ns")
1389            .await
1390            .expect_err("run_sync must reject invalid NDJSON");
1391        assert!(
1392            err.chain().any(|e| e.to_string().contains(expected_substr)),
1393            "error must mention {expected_substr:?}, got: {err:#}"
1394        );
1395
1396        let after = std::fs::read(db_path).unwrap();
1397        assert_eq!(
1398            after, b"SENTINEL",
1399            "rejected sync must leave the target DB completely untouched"
1400        );
1401    }
1402
1403    #[tokio::test]
1404    async fn sync_rejects_unknown_entity_kind_before_db_write() {
1405        let tmp = TempDir::new().unwrap();
1406        let repo = tmp.path();
1407        let db_path = repo.join(".khive/state/working.db");
1408        let id = "11111111-1111-1111-1111-111111111111";
1409        let entities = format!(
1410            r#"{{"id":"{id}","kind":"not-a-real-kind","name":"Bad","properties":{{}},"tags":[]}}"#
1411        );
1412
1413        assert_sync_rejected_before_db_write(repo, &db_path, &entities, "", "unknown kind").await;
1414    }
1415
1416    #[tokio::test]
1417    async fn sync_rejects_out_of_range_edge_weight_before_db_write() {
1418        let tmp = TempDir::new().unwrap();
1419        let repo = tmp.path();
1420        let db_path = repo.join(".khive/state/working.db");
1421        let id_a = "11111111-1111-1111-1111-111111111111";
1422        let id_b = "22222222-2222-2222-2222-222222222222";
1423        let entities = [
1424            format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1425            format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1426        ]
1427        .join("\n");
1428        let edge_id = "33333333-3333-3333-3333-333333333333";
1429        let edges = format!(
1430            r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":1.5,"properties":{{}}}}"#
1431        );
1432
1433        assert_sync_rejected_before_db_write(repo, &db_path, &entities, &edges, "out of range")
1434            .await;
1435    }
1436
1437    #[tokio::test]
1438    async fn sync_rejects_duplicate_entity_ids_before_db_write() {
1439        let tmp = TempDir::new().unwrap();
1440        let repo = tmp.path();
1441        let db_path = repo.join(".khive/state/working.db");
1442        let id = "11111111-1111-1111-1111-111111111111";
1443        let entities = [
1444            format!(r#"{{"id":"{id}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1445            format!(r#"{{"id":"{id}","kind":"concept","name":"A2","properties":{{}},"tags":[]}}"#),
1446        ]
1447        .join("\n");
1448
1449        assert_sync_rejected_before_db_write(repo, &db_path, &entities, "", "duplicate entity id")
1450            .await;
1451    }
1452
1453    #[tokio::test]
1454    async fn sync_rejects_duplicate_edge_ids_before_db_write() {
1455        let tmp = TempDir::new().unwrap();
1456        let repo = tmp.path();
1457        let db_path = repo.join(".khive/state/working.db");
1458        let id_a = "11111111-1111-1111-1111-111111111111";
1459        let id_b = "22222222-2222-2222-2222-222222222222";
1460        let id_c = "33333333-3333-3333-3333-333333333333";
1461        let entities = [
1462            format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1463            format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1464            format!(r#"{{"id":"{id_c}","kind":"concept","name":"C","properties":{{}},"tags":[]}}"#),
1465        ]
1466        .join("\n");
1467        let edge_id = "44444444-4444-4444-4444-444444444444";
1468        let edges = [
1469            format!(r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1470            format!(r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_c}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1471        ]
1472        .join("\n");
1473
1474        assert_sync_rejected_before_db_write(
1475            repo,
1476            &db_path,
1477            &entities,
1478            &edges,
1479            "duplicate edge id",
1480        )
1481        .await;
1482    }
1483
1484    #[tokio::test]
1485    async fn sync_rejects_duplicate_edge_triples_before_db_write() {
1486        let tmp = TempDir::new().unwrap();
1487        let repo = tmp.path();
1488        let db_path = repo.join(".khive/state/working.db");
1489        let id_a = "11111111-1111-1111-1111-111111111111";
1490        let id_b = "22222222-2222-2222-2222-222222222222";
1491        let entities = [
1492            format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1493            format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1494        ]
1495        .join("\n");
1496        let edge_id_1 = "33333333-3333-3333-3333-333333333333";
1497        let edge_id_2 = "44444444-4444-4444-4444-444444444444";
1498        let edges = [
1499            format!(r#"{{"edge_id":"{edge_id_1}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1500            format!(r#"{{"edge_id":"{edge_id_2}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.9,"properties":{{}}}}"#),
1501        ]
1502        .join("\n");
1503
1504        assert_sync_rejected_before_db_write(
1505            repo,
1506            &db_path,
1507            &entities,
1508            &edges,
1509            "duplicate edge triple",
1510        )
1511        .await;
1512    }
1513
1514    #[tokio::test]
1515    async fn sync_rejects_dangling_edge_source_before_db_write() {
1516        let tmp = TempDir::new().unwrap();
1517        let repo = tmp.path();
1518        let db_path = repo.join(".khive/state/working.db");
1519        let id_b = "22222222-2222-2222-2222-222222222222";
1520        let missing_source = "99999999-9999-9999-9999-999999999999";
1521        let entities =
1522            format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#);
1523        let edge_id = "33333333-3333-3333-3333-333333333333";
1524        let edges = format!(
1525            r#"{{"edge_id":"{edge_id}","source":"{missing_source}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}}}}"#
1526        );
1527
1528        assert_sync_rejected_before_db_write(repo, &db_path, &entities, &edges, "dangling source")
1529            .await;
1530    }
1531
1532    #[tokio::test]
1533    async fn sync_rejects_dangling_edge_target_before_db_write() {
1534        let tmp = TempDir::new().unwrap();
1535        let repo = tmp.path();
1536        let db_path = repo.join(".khive/state/working.db");
1537        let id_a = "11111111-1111-1111-1111-111111111111";
1538        let missing_target = "99999999-9999-9999-9999-999999999999";
1539        let entities =
1540            format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#);
1541        let edge_id = "33333333-3333-3333-3333-333333333333";
1542        let edges = format!(
1543            r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{missing_target}","relation":"extends","weight":0.5,"properties":{{}}}}"#
1544        );
1545
1546        assert_sync_rejected_before_db_write(repo, &db_path, &entities, &edges, "dangling target")
1547            .await;
1548    }
1549
1550    #[tokio::test]
1551    async fn sync_rejects_invalid_entity_timestamp_before_db_write() {
1552        let tmp = TempDir::new().unwrap();
1553        let repo = tmp.path();
1554        let db_path = repo.join(".khive/state/working.db");
1555        let id = "11111111-1111-1111-1111-111111111111";
1556        let entities = format!(
1557            r#"{{"id":"{id}","kind":"concept","name":"A","properties":{{}},"tags":[],"created_at":"not-a-timestamp"}}"#
1558        );
1559
1560        assert_sync_rejected_before_db_write(repo, &db_path, &entities, "", "invalid created_at")
1561            .await;
1562    }
1563
1564    #[tokio::test]
1565    async fn sync_rejects_invalid_edge_timestamp_before_db_write() {
1566        let tmp = TempDir::new().unwrap();
1567        let repo = tmp.path();
1568        let db_path = repo.join(".khive/state/working.db");
1569        let id_a = "11111111-1111-1111-1111-111111111111";
1570        let id_b = "22222222-2222-2222-2222-222222222222";
1571        let entities = [
1572            format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1573            format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1574        ]
1575        .join("\n");
1576        let edge_id = "33333333-3333-3333-3333-333333333333";
1577        let edges = format!(
1578            r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}},"updated_at":"not-a-timestamp"}}"#
1579        );
1580
1581        assert_sync_rejected_before_db_write(
1582            repo,
1583            &db_path,
1584            &entities,
1585            &edges,
1586            "invalid updated_at",
1587        )
1588        .await;
1589    }
1590
1591    #[tokio::test]
1592    async fn sync_rejects_unsorted_entities_before_db_write() {
1593        let tmp = TempDir::new().unwrap();
1594        let repo = tmp.path();
1595        let db_path = repo.join(".khive/state/working.db");
1596        // "b..." sorts after "a...", so writing it first violates the
1597        // ascending-by-lowercase-id order that `write_sorted_entities` enforces.
1598        let id_hi = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
1599        let id_lo = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
1600        let entities = [
1601            format!(
1602                r#"{{"id":"{id_hi}","kind":"concept","name":"Hi","properties":{{}},"tags":[]}}"#
1603            ),
1604            format!(
1605                r#"{{"id":"{id_lo}","kind":"concept","name":"Lo","properties":{{}},"tags":[]}}"#
1606            ),
1607        ]
1608        .join("\n");
1609
1610        assert_sync_rejected_before_db_write(
1611            repo,
1612            &db_path,
1613            &entities,
1614            "",
1615            "entities.ndjson is not sorted",
1616        )
1617        .await;
1618    }
1619
1620    #[tokio::test]
1621    async fn sync_rejects_unsorted_edges_before_db_write() {
1622        let tmp = TempDir::new().unwrap();
1623        let repo = tmp.path();
1624        let db_path = repo.join(".khive/state/working.db");
1625        let id_a = "11111111-1111-1111-1111-111111111111";
1626        let id_b = "22222222-2222-2222-2222-222222222222";
1627        let id_c = "33333333-3333-3333-3333-333333333333";
1628        let entities = [
1629            format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1630            format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1631            format!(r#"{{"id":"{id_c}","kind":"concept","name":"C","properties":{{}},"tags":[]}}"#),
1632        ]
1633        .join("\n");
1634        let edge_id_1 = "44444444-4444-4444-4444-444444444444";
1635        let edge_id_2 = "55555555-5555-5555-5555-555555555555";
1636        // (source=c, target=a) sorts after (source=a, target=b) lexicographically
1637        // by UUID string, so writing it first violates edge sort order.
1638        let edges = [
1639            format!(r#"{{"edge_id":"{edge_id_1}","source":"{id_c}","target":"{id_a}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1640            format!(r#"{{"edge_id":"{edge_id_2}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1641        ]
1642        .join("\n");
1643
1644        assert_sync_rejected_before_db_write(
1645            repo,
1646            &db_path,
1647            &entities,
1648            &edges,
1649            "edges.ndjson is not sorted",
1650        )
1651        .await;
1652    }
1653
1654    /// #473: `run_sync` must preserve `entity_type` from NDJSON into the
1655    /// SQLite-backed entity store so subtype-filtered reads see it.
1656    #[tokio::test]
1657    async fn sync_preserves_entity_type() {
1658        let tmp = TempDir::new().unwrap();
1659        let repo = tmp.path();
1660        let db_path = repo.join(".khive/state/working.db");
1661
1662        let id_a = "44444444-4444-4444-4444-444444444444";
1663        let line_a = format!(
1664            r#"{{"id":"{id_a}","kind":"document","entity_type":"paper","name":"Attention Is All You Need","properties":{{}},"tags":[]}}"#
1665        );
1666        write_repo(repo, &line_a, "");
1667
1668        let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1669        assert_eq!(report.entities, 1);
1670
1671        let ns = khive_types::Namespace::parse("test-ns").unwrap();
1672        let config = RuntimeConfig {
1673            db_path: Some(db_path.clone()),
1674            default_namespace: ns.clone(),
1675            embedding_model: None,
1676            ..RuntimeConfig::default()
1677        };
1678        let rt = KhiveRuntime::new(config).unwrap();
1679        let token = rt.authorize(ns).unwrap();
1680        let entity = rt
1681            .entities(&token)
1682            .unwrap()
1683            .get_entity(id_a.parse().unwrap())
1684            .await
1685            .unwrap()
1686            .expect("entity must be retrievable after sync");
1687        assert_eq!(
1688            entity.entity_type.as_deref(),
1689            Some("paper"),
1690            "entity_type must survive NDJSON sync, not be stored as NULL"
1691        );
1692    }
1693
1694    /// #473: two archives differing ONLY by `entity_type` must hash to
1695    /// different `SnapshotId`s — the pin must be injective over entity_type,
1696    /// not collide with the untyped archive.
1697    #[test]
1698    fn remote_hash_includes_ndjson_entity_type() {
1699        let id_a = "55555555-5555-5555-5555-555555555555";
1700        let untyped = format!(
1701            r#"{{"id":"{id_a}","kind":"document","name":"Some Doc","properties":{{}},"tags":[]}}"#
1702        );
1703        let typed = format!(
1704            r#"{{"id":"{id_a}","kind":"document","entity_type":"paper","name":"Some Doc","properties":{{}},"tags":[]}}"#
1705        );
1706
1707        let untyped_pin = compute_pin(&untyped, "", "test-ns");
1708        let typed_pin = compute_pin(&typed, "", "test-ns");
1709
1710        assert_ne!(
1711            untyped_pin.as_str(),
1712            typed_pin.as_str(),
1713            "entity_type must be part of the canonical hash input; a pin for the \
1714             untyped archive must not validate typed content"
1715        );
1716    }
1717
1718    /// F195: verify that FTS5 is populated during sync so text search works
1719    /// after sync without a separate `kkernel kg embed` pass.
1720    #[tokio::test]
1721    async fn sync_populates_fts_for_text_search() {
1722        use khive_runtime::RuntimeConfig;
1723        use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
1724
1725        let tmp = TempDir::new().unwrap();
1726        let repo = tmp.path();
1727        let db_path = repo.join(".khive/state/working.db");
1728
1729        let id_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
1730        let line_a = format!(
1731            r#"{{"id":"{id_a}","kind":"concept","name":"FlashAttention","description":"Fast attention algorithm","properties":{{}},"tags":[]}}"#
1732        );
1733        write_repo(repo, &line_a, "");
1734
1735        run_sync(repo, &db_path, "test-ns").await.unwrap();
1736
1737        let ns = khive_types::Namespace::parse("test-ns").unwrap();
1738        let config = RuntimeConfig {
1739            db_path: Some(db_path.clone()),
1740            default_namespace: ns.clone(),
1741            embedding_model: None,
1742            ..RuntimeConfig::default()
1743        };
1744        let rt = KhiveRuntime::new(config).unwrap();
1745        let token = rt.authorize(ns).unwrap();
1746
1747        let hits = rt
1748            .text(&token)
1749            .expect("text store must be available")
1750            .search(TextSearchRequest {
1751                query: "FlashAttention".to_string(),
1752                filter: Some(TextFilter {
1753                    namespaces: vec!["test-ns".to_string()],
1754                    ..Default::default()
1755                }),
1756                mode: TextQueryMode::Phrase,
1757                top_k: 10,
1758                snippet_chars: 128,
1759            })
1760            .await
1761            .expect("text search must succeed after sync");
1762
1763        assert!(
1764            !hits.is_empty(),
1765            "FTS search for 'FlashAttention' must return results after sync (F195)"
1766        );
1767        assert_eq!(
1768            hits[0].subject_id.to_string(),
1769            id_a,
1770            "FTS hit must reference the synced entity UUID"
1771        );
1772    }
1773
1774    // ── #474 RemoteName tests (VCS-AUD-002) ─────────────────────────────────────
1775
1776    /// #474: `RemoteName::parse` must reject every path-traversal / absolute
1777    /// / separator-containing shape before it can reach `run_sync_remote`.
1778    #[test]
1779    fn remote_name_rejects_path_traversal_cases() {
1780        for bad in [
1781            "",
1782            ".",
1783            "..",
1784            "../evil",
1785            "../../outside",
1786            "/tmp/evil",
1787            "safe/name",
1788            "safe\\name",
1789            "safe/../evil",
1790        ] {
1791            assert!(
1792                RemoteName::parse(bad).is_err(),
1793                "expected RemoteName::parse to reject {bad:?}"
1794            );
1795        }
1796    }
1797
1798    /// #474: safe single-segment names must be accepted and preserved verbatim.
1799    #[test]
1800    fn remote_name_accepts_single_safe_segment() {
1801        for good in ["upstream", "team.data-1", "remote_2"] {
1802            let parsed = RemoteName::parse(good).expect("expected safe name to be accepted");
1803            assert_eq!(parsed.as_str(), good);
1804        }
1805    }
1806
1807    /// Issue #474: path-traversal remote names must be unconstructable. See
1808    /// `docs/api/sync.md#remotename--construction-time-path-traversal-safety`.
1809    #[tokio::test]
1810    async fn run_sync_remote_cannot_be_constructed_with_invalid_name() {
1811        let repo_dir = TempDir::new().unwrap();
1812        let outside_target = repo_dir.path().parent().unwrap().join("evil-outside-probe");
1813        let _ = std::fs::remove_file(&outside_target);
1814
1815        for bad in ["../evil", "/tmp/evil", "safe/name"] {
1816            assert!(
1817                RemoteName::parse(bad).is_err(),
1818                "RemoteName::parse must reject {bad:?} before any RemoteConfig can be built"
1819            );
1820        }
1821
1822        // No RemoteConfig could be built from these names, so run_sync_remote was
1823        // never called: the cache tree and any traversal target are both absent.
1824        assert!(
1825            !repo_dir.path().join(".khive/kg/remotes").exists(),
1826            "cache tree must not exist — no sync ever ran"
1827        );
1828        assert!(
1829            !std::path::Path::new("/tmp/evil").exists(),
1830            "/tmp/evil must not have been created by this test run"
1831        );
1832        assert!(
1833            !outside_target.exists(),
1834            "nothing must be written outside the repo root"
1835        );
1836    }
1837
1838    // ── #475 atomic publish failure-injection tests (VCS-AUD-003) ───────────────
1839
1840    fn sample_entity(id: &str, name: &str) -> NdjsonEntity {
1841        NdjsonEntity {
1842            id: id.parse().unwrap(),
1843            kind: "concept".to_string(),
1844            entity_type: None,
1845            name: name.to_string(),
1846            description: None,
1847            properties: Some(serde_json::json!({})),
1848            tags: vec![],
1849            created_at: None,
1850            updated_at: None,
1851        }
1852    }
1853
1854    fn sample_meta(tag: &str) -> MetaJson {
1855        MetaJson {
1856            fetched_at: "2026-01-01T00:00:00Z".to_string(),
1857            git_ref: "main".to_string(),
1858            commit_sha: "deadbeef".to_string(),
1859            content_hash: format!("sha256:{tag}"),
1860        }
1861    }
1862
1863    /// Publish an "old" cache generation successfully, used as the baseline
1864    /// state that a subsequent failed publish must leave untouched.
1865    fn publish_old_generation(remotes_root: &Path, name: &str) -> PathBuf {
1866        let entities = vec![sample_entity(
1867            "11111111-1111-1111-1111-111111111111",
1868            "OldEntity",
1869        )];
1870        publish_remote_cache(
1871            remotes_root,
1872            name,
1873            &entities,
1874            &[],
1875            &sample_meta("old"),
1876            None,
1877        )
1878        .expect("baseline publish must succeed")
1879    }
1880
1881    fn assert_cache_is_old_generation(cache_dir: &Path) {
1882        let entities = std::fs::read_to_string(cache_dir.join("entities.ndjson")).unwrap();
1883        assert!(
1884            entities.contains("OldEntity"),
1885            "cache entities must still be the old generation, got: {entities}"
1886        );
1887        let meta: serde_json::Value =
1888            serde_json::from_str(&std::fs::read_to_string(cache_dir.join("meta.json")).unwrap())
1889                .unwrap();
1890        assert_eq!(meta["content_hash"], "sha256:old");
1891    }
1892
1893    #[test]
1894    fn remote_cache_publish_failure_after_entities_keeps_old_cache() {
1895        let tmp = TempDir::new().unwrap();
1896        let remotes_root = tmp.path().join(".khive/kg/remotes");
1897        let cache_dir = publish_old_generation(&remotes_root, "upstream");
1898
1899        let new_entities = vec![sample_entity(
1900            "22222222-2222-2222-2222-222222222222",
1901            "NewEntity",
1902        )];
1903        let err = publish_remote_cache(
1904            &remotes_root,
1905            "upstream",
1906            &new_entities,
1907            &[],
1908            &sample_meta("new"),
1909            Some(PublishFailAt::AfterEntities),
1910        )
1911        .expect_err("injected failure must surface as an error");
1912        assert!(err.to_string().contains("injected failure"));
1913
1914        assert_cache_is_old_generation(&cache_dir);
1915    }
1916
1917    #[test]
1918    fn remote_cache_publish_failure_after_edges_keeps_old_cache() {
1919        let tmp = TempDir::new().unwrap();
1920        let remotes_root = tmp.path().join(".khive/kg/remotes");
1921        let cache_dir = publish_old_generation(&remotes_root, "upstream");
1922
1923        let new_entities = vec![sample_entity(
1924            "22222222-2222-2222-2222-222222222222",
1925            "NewEntity",
1926        )];
1927        let err = publish_remote_cache(
1928            &remotes_root,
1929            "upstream",
1930            &new_entities,
1931            &[],
1932            &sample_meta("new"),
1933            Some(PublishFailAt::AfterEdges),
1934        )
1935        .expect_err("injected failure must surface as an error");
1936        assert!(err.to_string().contains("injected failure"));
1937
1938        assert_cache_is_old_generation(&cache_dir);
1939    }
1940
1941    #[test]
1942    fn remote_cache_publish_failure_after_meta_keeps_old_cache() {
1943        let tmp = TempDir::new().unwrap();
1944        let remotes_root = tmp.path().join(".khive/kg/remotes");
1945        let cache_dir = publish_old_generation(&remotes_root, "upstream");
1946
1947        let new_entities = vec![sample_entity(
1948            "22222222-2222-2222-2222-222222222222",
1949            "NewEntity",
1950        )];
1951        let err = publish_remote_cache(
1952            &remotes_root,
1953            "upstream",
1954            &new_entities,
1955            &[],
1956            &sample_meta("new"),
1957            Some(PublishFailAt::AfterMeta),
1958        )
1959        .expect_err("injected failure must surface as an error");
1960        assert!(err.to_string().contains("injected failure"));
1961
1962        assert_cache_is_old_generation(&cache_dir);
1963    }
1964
1965    /// Failure injected immediately before the directory swap: the staged
1966    /// directory is fully built but never made visible, so the reader-visible
1967    /// cache must still be exactly the old generation.
1968    #[test]
1969    fn remote_cache_publish_failure_before_swap_keeps_old_cache() {
1970        let tmp = TempDir::new().unwrap();
1971        let remotes_root = tmp.path().join(".khive/kg/remotes");
1972        let cache_dir = publish_old_generation(&remotes_root, "upstream");
1973
1974        let new_entities = vec![sample_entity(
1975            "22222222-2222-2222-2222-222222222222",
1976            "NewEntity",
1977        )];
1978        let err = publish_remote_cache(
1979            &remotes_root,
1980            "upstream",
1981            &new_entities,
1982            &[],
1983            &sample_meta("new"),
1984            Some(PublishFailAt::BeforeSwap),
1985        )
1986        .expect_err("injected failure must surface as an error");
1987        assert!(err.to_string().contains("injected failure"));
1988
1989        assert_cache_is_old_generation(&cache_dir);
1990    }
1991
1992    /// A successful publish exposes the complete new entities+edges+meta
1993    /// together — never a partial mix with the previous generation.
1994    #[test]
1995    fn remote_cache_publish_success_exposes_complete_new_cache() {
1996        let tmp = TempDir::new().unwrap();
1997        let remotes_root = tmp.path().join(".khive/kg/remotes");
1998        let cache_dir = publish_old_generation(&remotes_root, "upstream");
1999        assert_cache_is_old_generation(&cache_dir);
2000
2001        let new_entities = vec![sample_entity(
2002            "22222222-2222-2222-2222-222222222222",
2003            "NewEntity",
2004        )];
2005        let published = publish_remote_cache(
2006            &remotes_root,
2007            "upstream",
2008            &new_entities,
2009            &[],
2010            &sample_meta("new"),
2011            None,
2012        )
2013        .expect("publish must succeed");
2014        assert_eq!(published, cache_dir);
2015
2016        let entities = std::fs::read_to_string(cache_dir.join("entities.ndjson")).unwrap();
2017        assert!(entities.contains("NewEntity"));
2018        assert!(
2019            !entities.contains("OldEntity"),
2020            "old generation entity must not linger after a successful publish"
2021        );
2022        let meta: serde_json::Value =
2023            serde_json::from_str(&std::fs::read_to_string(cache_dir.join("meta.json")).unwrap())
2024                .unwrap();
2025        assert_eq!(meta["content_hash"], "sha256:new");
2026    }
2027
2028    // ── F201 tests ────────────────────────────────────────────────────────────
2029
2030    /// F201-1: `run_sync_remote` with a correct pin succeeds and writes the
2031    /// expected cache files and `meta.json`.
2032    #[tokio::test]
2033    async fn run_sync_remote_fetches_and_verifies_hash_match() {
2034        let remote_dir = TempDir::new().unwrap();
2035        let repo_dir = TempDir::new().unwrap();
2036
2037        let id_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
2038        let entities = format!(
2039            r#"{{"id":"{id_a}","kind":"concept","name":"RemoteEntity","properties":{{}},"tags":[]}}"#
2040        );
2041        let edges = "";
2042
2043        let remote_url = make_git_remote(remote_dir.path(), &entities, edges);
2044        let expected_pin = compute_pin(&entities, edges, "remote-ns");
2045
2046        let remote = RemoteConfig {
2047            name: RemoteName::parse("upstream").unwrap(),
2048            url: remote_url,
2049            git_ref: "main".to_string(),
2050            namespace: "remote-ns".to_string(),
2051            pin: Some(expected_pin.clone()),
2052        };
2053
2054        let report = run_sync_remote(repo_dir.path(), &remote, false)
2055            .await
2056            .expect("run_sync_remote must succeed with correct pin");
2057
2058        assert_eq!(report.entities, 1, "must report 1 entity");
2059        assert_eq!(report.edges, 0, "must report 0 edges");
2060        assert_eq!(
2061            report.content_hash,
2062            expected_pin.as_str(),
2063            "content_hash must match the pin"
2064        );
2065        assert!(!report.repinned, "repin was not requested");
2066
2067        // Cache files must exist.
2068        let cache = repo_dir.path().join(".khive/kg/remotes/upstream");
2069        assert!(
2070            cache.join("entities.ndjson").exists(),
2071            "entities.ndjson must exist in cache"
2072        );
2073        assert!(
2074            cache.join("edges.ndjson").exists(),
2075            "edges.ndjson must exist in cache"
2076        );
2077        assert!(
2078            cache.join("meta.json").exists(),
2079            "meta.json must exist in cache"
2080        );
2081
2082        // meta.json must be valid JSON with the expected fields.
2083        let meta_bytes = std::fs::read(cache.join("meta.json")).unwrap();
2084        let meta: serde_json::Value = serde_json::from_slice(&meta_bytes).unwrap();
2085        assert_eq!(
2086            meta["content_hash"].as_str().unwrap(),
2087            expected_pin.as_str(),
2088            "meta.json content_hash must match"
2089        );
2090        assert!(
2091            meta["fetched_at"].as_str().is_some(),
2092            "meta.json must have fetched_at"
2093        );
2094        assert!(
2095            meta["commit_sha"].as_str().is_some(),
2096            "meta.json must have commit_sha"
2097        );
2098    }
2099
2100    /// F201-2: `run_sync_remote` with a wrong pin fails before touching the
2101    /// cache (fail-closed guarantee).
2102    #[tokio::test]
2103    async fn run_sync_remote_rejects_hash_mismatch() {
2104        let remote_dir = TempDir::new().unwrap();
2105        let repo_dir = TempDir::new().unwrap();
2106
2107        let id_b = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
2108        let entities = format!(
2109            r#"{{"id":"{id_b}","kind":"concept","name":"AnotherEntity","properties":{{}},"tags":[]}}"#
2110        );
2111        let edges = "";
2112
2113        let remote_url = make_git_remote(remote_dir.path(), &entities, edges);
2114
2115        // Deliberate wrong pin: 64 zero hex chars.
2116        let wrong_pin = SnapshotId::from_hash(&"0".repeat(64)).unwrap();
2117
2118        let remote = RemoteConfig {
2119            name: RemoteName::parse("upstream").unwrap(),
2120            url: remote_url,
2121            git_ref: "main".to_string(),
2122            namespace: "remote-ns".to_string(),
2123            pin: Some(wrong_pin.clone()),
2124        };
2125
2126        let err = run_sync_remote(repo_dir.path(), &remote, false)
2127            .await
2128            .expect_err("run_sync_remote must fail on hash mismatch");
2129
2130        let err_msg = err.to_string();
2131        assert!(
2132            err_msg.contains("hash mismatch") || err_msg.contains("sha256:"),
2133            "error must mention hash mismatch, got: {err_msg}"
2134        );
2135
2136        // Cache must NOT have been written (fail-closed).
2137        let cache = repo_dir.path().join(".khive/kg/remotes/upstream");
2138        assert!(
2139            !cache.join("entities.ndjson").exists(),
2140            "entities.ndjson must NOT exist after mismatch"
2141        );
2142        assert!(
2143            !cache.join("meta.json").exists(),
2144            "meta.json must NOT exist after mismatch"
2145        );
2146    }
2147
2148    /// F201-3: `run_sync_remote` with no pin still proceeds and writes `meta.json`
2149    /// (hash is still computed and written for auditability).
2150    #[tokio::test]
2151    async fn run_sync_remote_no_pin_proceeds_and_writes_meta() {
2152        let remote_dir = TempDir::new().unwrap();
2153        let repo_dir = TempDir::new().unwrap();
2154
2155        let id_c = "cccccccc-cccc-cccc-cccc-cccccccccccc";
2156        let entities = format!(
2157            r#"{{"id":"{id_c}","kind":"concept","name":"Pinless","properties":{{}},"tags":[]}}"#
2158        );
2159
2160        let remote_url = make_git_remote(remote_dir.path(), &entities, "");
2161
2162        let remote = RemoteConfig {
2163            name: RemoteName::parse("no-pin-remote").unwrap(),
2164            url: remote_url,
2165            git_ref: "main".to_string(),
2166            namespace: "remote-ns".to_string(),
2167            pin: None,
2168        };
2169
2170        let report = run_sync_remote(repo_dir.path(), &remote, false)
2171            .await
2172            .expect("run_sync_remote must succeed with no pin");
2173
2174        assert_eq!(report.entities, 1);
2175        assert!(
2176            report.content_hash.starts_with("sha256:"),
2177            "content_hash must have sha256: prefix even without pin"
2178        );
2179
2180        let cache = repo_dir.path().join(".khive/kg/remotes/no-pin-remote");
2181        assert!(
2182            cache.join("meta.json").exists(),
2183            "meta.json must be written even when pin is absent"
2184        );
2185    }
2186
2187    /// F201-4: `--repin` skips pin comparison and returns the actual hash,
2188    /// allowing the caller to update `schema.yaml`.
2189    #[tokio::test]
2190    async fn run_sync_remote_repin_updates_hash_ignoring_old_pin() {
2191        let remote_dir = TempDir::new().unwrap();
2192        let repo_dir = TempDir::new().unwrap();
2193
2194        let id_d = "dddddddd-dddd-dddd-dddd-dddddddddddd";
2195        let entities = format!(
2196            r#"{{"id":"{id_d}","kind":"concept","name":"RepinTarget","properties":{{}},"tags":[]}}"#
2197        );
2198
2199        let remote_url = make_git_remote(remote_dir.path(), &entities, "");
2200        let actual_hash = compute_pin(&entities, "", "repin-ns");
2201
2202        // Deliberately stale/wrong pin — repin must ignore it.
2203        let stale_pin = SnapshotId::from_hash(&"f".repeat(64)).unwrap();
2204
2205        let remote = RemoteConfig {
2206            name: RemoteName::parse("repinned").unwrap(),
2207            url: remote_url,
2208            git_ref: "main".to_string(),
2209            namespace: "repin-ns".to_string(),
2210            pin: Some(stale_pin),
2211        };
2212
2213        let report = run_sync_remote(repo_dir.path(), &remote, true)
2214            .await
2215            .expect("repin must succeed even with wrong existing pin");
2216
2217        assert!(report.repinned, "repinned flag must be true");
2218        assert_eq!(
2219            report.content_hash,
2220            actual_hash.as_str(),
2221            "repinned hash must be the actual fetched archive hash"
2222        );
2223
2224        // Cache must be populated.
2225        let cache = repo_dir.path().join(".khive/kg/remotes/repinned");
2226        assert!(cache.join("entities.ndjson").exists());
2227        assert!(cache.join("meta.json").exists());
2228    }
2229
2230    // ── URL redaction tests ───────────────────────────────────────────────────
2231
2232    /// Credentials embedded in a URL must not survive redact_git_stderr.
2233    #[test]
2234    fn redact_strips_credential_url() {
2235        let raw = "fatal: Authentication failed for 'https://user:token@host/repo.git'";
2236        let out = redact_git_stderr(raw);
2237        assert!(
2238            !out.contains("user:token"),
2239            "credential must be redacted, got: {out}"
2240        );
2241        assert!(
2242            !out.contains("host/repo.git"),
2243            "host/path must be redacted, got: {out}"
2244        );
2245        assert!(
2246            out.contains("<url-redacted>"),
2247            "placeholder must be present, got: {out}"
2248        );
2249    }
2250
2251    /// Plain text without a URL must pass through unchanged.
2252    #[test]
2253    fn redact_passes_plain_text() {
2254        let raw = "error: unable to read refs from remote";
2255        assert_eq!(redact_git_stderr(raw), raw);
2256    }
2257
2258    /// Multiple URLs in the same stderr string must all be redacted.
2259    #[test]
2260    fn redact_handles_multiple_urls() {
2261        let raw = "fetch https://a:b@host1/r1.git and push https://c:d@host2/r2.git failed";
2262        let out = redact_git_stderr(raw);
2263        assert!(!out.contains("a:b"), "first credential must be redacted");
2264        assert!(!out.contains("c:d"), "second credential must be redacted");
2265        assert_eq!(
2266            out.matches("<url-redacted>").count(),
2267            2,
2268            "both URLs must be replaced"
2269        );
2270    }
2271
2272    /// A bare URL without credentials is also redacted (the host is still sensitive).
2273    #[test]
2274    fn redact_handles_url_without_credentials() {
2275        let raw = "fatal: repository 'https://github.com/org/private-repo.git/' not found";
2276        let out = redact_git_stderr(raw);
2277        assert!(
2278            !out.contains("github.com/org/private-repo"),
2279            "URL path must be redacted"
2280        );
2281        assert!(
2282            out.contains("<url-redacted>"),
2283            "placeholder must be present"
2284        );
2285    }
2286
2287    /// scp-style `git@host:org/repo.git` must be fully redacted.
2288    #[test]
2289    fn redact_strips_scp_style_remote() {
2290        let raw = "ERROR: Repository not found.\nfatal: Could not read from remote repository git@github.com:org/private-repo.git";
2291        let out = redact_git_stderr(raw);
2292        assert!(
2293            !out.contains("git@"),
2294            "scp userinfo must be redacted, got: {out}"
2295        );
2296        assert!(
2297            !out.contains("github.com"),
2298            "scp host must be redacted, got: {out}"
2299        );
2300        assert!(
2301            !out.contains("private-repo"),
2302            "scp path must be redacted, got: {out}"
2303        );
2304        assert!(
2305            out.contains("<url-redacted>"),
2306            "placeholder must be present, got: {out}"
2307        );
2308    }
2309
2310    /// `user@host:path` (non-git@ prefix) must also be redacted.
2311    #[test]
2312    fn redact_strips_user_at_host_colon_path() {
2313        let raw = "fatal: repository user@bitbucket.org:myteam/myrepo.git not found";
2314        let out = redact_git_stderr(raw);
2315        assert!(
2316            !out.contains("user@"),
2317            "userinfo must be redacted, got: {out}"
2318        );
2319        assert!(
2320            !out.contains("bitbucket.org"),
2321            "host must be redacted, got: {out}"
2322        );
2323        assert!(
2324            out.contains("<url-redacted>"),
2325            "placeholder must be present, got: {out}"
2326        );
2327    }
2328
2329    /// Plain `host:path` without a `user@` prefix must NOT be over-redacted
2330    /// (it is not a recognised remote form).
2331    #[test]
2332    fn redact_does_not_over_redact_plain_colon() {
2333        let raw = "error: src refspec main does not match any";
2334        let out = redact_git_stderr(raw);
2335        assert_eq!(out, raw, "plain text with colon must not be altered");
2336    }
2337
2338    // ── Public error boundary tests ───────────────────────────────────────────
2339    //
2340    // These tests verify that the sanitiser is wired into the actual public error
2341    // path (the `anyhow` error returned by `run_sync_remote`).  They use realistic
2342    // git-stderr fragments — the kind git emits when a clone fails for auth or
2343    // network reasons — and assert that the rendered error string contains no raw
2344    // credentials or remote-URL tokens.
2345    //
2346    // The FAIL-before / PASS-after property is demonstrated by the
2347    // `redact_git_stderr` unit tests above (which call the function directly)
2348    // combined with these wiring tests that confirm the sanitised output is what
2349    // the caller actually sees in `err.to_string()`.
2350
2351    /// Credential-bearing HTTPS URLs must not leak into the public error
2352    /// string. See `docs/api/sync.md#credential-redaction-in-git-error-output--redact_git_stderr`.
2353    #[tokio::test]
2354    async fn public_error_redacts_https_credential_url() {
2355        let repo_dir = tempfile::TempDir::new().unwrap();
2356        // Use a credential-bearing HTTPS URL that will fail immediately.
2357        let remote = RemoteConfig {
2358            name: RemoteName::parse("cred-test").unwrap(),
2359            url: "https://user:secret_token@nonexistent.example.invalid/org/repo.git".to_string(),
2360            git_ref: "main".to_string(),
2361            namespace: "test-ns".to_string(),
2362            pin: None,
2363        };
2364        let err = run_sync_remote(repo_dir.path(), &remote, false)
2365            .await
2366            .expect_err("clone of nonexistent URL must fail");
2367
2368        let err_str = err.to_string();
2369        let err_chain: String = err
2370            .chain()
2371            .map(|e| e.to_string())
2372            .collect::<Vec<_>>()
2373            .join(" | ");
2374
2375        assert!(
2376            !err_str.contains("secret_token") && !err_chain.contains("secret_token"),
2377            "credential must not appear in public error string or chain: {err_str} | {err_chain}"
2378        );
2379        assert!(
2380            !err_str.contains("user:") && !err_chain.contains("user:"),
2381            "userinfo must not appear in public error: {err_str} | {err_chain}"
2382        );
2383        // The remote name is used in the error, but the URL must not be.
2384        assert!(
2385            err_str.contains("cred-test") || err_chain.contains("cred-test"),
2386            "remote name must be present for diagnostics: {err_str} | {err_chain}"
2387        );
2388    }
2389
2390    /// scp-style `git@host:org/repo.git` must not leak through the sanitiser.
2391    /// See `docs/api/sync.md#credential-redaction-in-git-error-output--redact_git_stderr`.
2392    #[tokio::test]
2393    async fn public_error_redacts_scp_style_remote() {
2394        let repo_dir = tempfile::TempDir::new().unwrap();
2395        let remote = RemoteConfig {
2396            name: RemoteName::parse("scp-test").unwrap(),
2397            url: "git@nonexistent.example.invalid:org/private-repo.git".to_string(),
2398            git_ref: "main".to_string(),
2399            namespace: "test-ns".to_string(),
2400            pin: None,
2401        };
2402        let err = run_sync_remote(repo_dir.path(), &remote, false)
2403            .await
2404            .expect_err("clone of nonexistent scp remote must fail");
2405
2406        let err_str = err.to_string();
2407        let err_chain: String = err
2408            .chain()
2409            .map(|e| e.to_string())
2410            .collect::<Vec<_>>()
2411            .join(" | ");
2412
2413        // The remote URL must not appear verbatim in the public error.
2414        // git does not echo scp URLs into stderr on SSH-level failures —
2415        // the host appears in SSH's own message, not from URL echoing.
2416        // We assert that our scp token (user@host:path form) is absent.
2417        assert!(
2418            !err_str.contains("git@nonexistent.example.invalid")
2419                && !err_chain.contains("git@nonexistent.example.invalid"),
2420            "scp remote URL must not appear in public error: {err_str} | {err_chain}"
2421        );
2422        assert!(
2423            !err_str.contains("private-repo") && !err_chain.contains("private-repo"),
2424            "scp repo path must not appear in public error: {err_str} | {err_chain}"
2425        );
2426        // Remote name must still be present for diagnostics.
2427        assert!(
2428            err_str.contains("scp-test") || err_chain.contains("scp-test"),
2429            "remote name must be present for diagnostics: {err_str} | {err_chain}"
2430        );
2431    }
2432
2433    /// Regression: VCS sync FTS document must be field-identical to
2434    /// `entity_fts_document`'s output. See
2435    /// `docs/api/sync.md#fts-document-consistency`.
2436    #[test]
2437    fn sync_fts_document_matches_entity_fts_document() {
2438        use khive_runtime::entity_fts_document;
2439        use khive_storage::SubstrateKind;
2440
2441        let id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap();
2442        let props: Option<serde_json::Value> =
2443            Some(serde_json::json!({"domain": "attention", "status": "researched"}));
2444
2445        let entity = khive_storage::entity::Entity {
2446            id,
2447            namespace: "test-ns".to_string(),
2448            kind: "concept".to_string(),
2449            entity_type: None,
2450            name: "FlashAttention".to_string(),
2451            description: Some("Fast attention algorithm".to_string()),
2452            properties: props.clone(),
2453            tags: vec!["attention".to_string(), "inference".to_string()],
2454            created_at: 1_000_000,
2455            updated_at: 2_000_000,
2456            deleted_at: None,
2457            merge_event_id: None,
2458            merged_into: None,
2459            content_ref: None,
2460        };
2461
2462        let doc = entity_fts_document(&entity);
2463
2464        assert_eq!(doc.subject_id, id);
2465        assert_eq!(doc.kind, SubstrateKind::Entity);
2466        assert_eq!(doc.namespace, "test-ns");
2467        assert_eq!(doc.title.as_deref(), Some("FlashAttention"));
2468        assert_eq!(doc.body, "FlashAttention Fast attention algorithm");
2469        assert_eq!(
2470            doc.tags,
2471            vec!["attention".to_string(), "inference".to_string()]
2472        );
2473        assert_eq!(doc.metadata, props);
2474        assert_eq!(
2475            doc.updated_at,
2476            chrono::DateTime::from_timestamp_micros(2_000_000).unwrap()
2477        );
2478    }
2479
2480    /// `user@host:path` scp form must not appear in the public error string.
2481    #[tokio::test]
2482    async fn public_error_redacts_user_pass_at_host_scp() {
2483        let repo_dir = tempfile::TempDir::new().unwrap();
2484        let remote = RemoteConfig {
2485            name: RemoteName::parse("userpass-scp").unwrap(),
2486            url: "deploy@nonexistent.example.invalid:infra/secret-repo.git".to_string(),
2487            git_ref: "main".to_string(),
2488            namespace: "test-ns".to_string(),
2489            pin: None,
2490        };
2491        let err = run_sync_remote(repo_dir.path(), &remote, false)
2492            .await
2493            .expect_err("clone of nonexistent scp remote must fail");
2494
2495        let err_str = err.to_string();
2496        let err_chain: String = err
2497            .chain()
2498            .map(|e| e.to_string())
2499            .collect::<Vec<_>>()
2500            .join(" | ");
2501
2502        assert!(
2503            !err_str.contains("deploy@nonexistent.example.invalid")
2504                && !err_chain.contains("deploy@nonexistent.example.invalid"),
2505            "scp userinfo+host must not appear in public error: {err_str} | {err_chain}"
2506        );
2507        assert!(
2508            !err_str.contains("secret-repo") && !err_chain.contains("secret-repo"),
2509            "scp repo path must not appear in public error: {err_str} | {err_chain}"
2510        );
2511    }
2512}
2513
2514// ── ADR-067 Fork C slice 2 (sibling of memory.vacuum's Fork C slice 2
2515// fix): `checkpoint_wal` under the write queue ───────────────────────────
2516//
2517// `checkpoint_wal` used to send `"PRAGMA wal_checkpoint(TRUNCATE);"` via plain
2518// `execute_script`, which — once `execute_script` started routing through the
2519// WriterTask (ADR-067 Component A) under `KHIVE_WRITE_QUEUE=1` — landed inside
2520// the WriterTask's own per-request `BEGIN IMMEDIATE`. A checkpoint cannot
2521// complete while a transaction is open on the same connection, so the
2522// checkpoint would silently no-op and the subsequent `rename(tmp, target)`
2523// (see the comment at the `checkpoint_wal` call site above) would drop any
2524// writes still sitting in the `-wal` file. This proves the fixed
2525// `execute_script_top_level` path (no BEGIN/COMMIT/ROLLBACK wrap) succeeds
2526// with the write queue enabled.
2527//
2528// `KhiveRuntime` has no config-injection point for `PoolConfig` (production
2529// construction hardcodes `PoolConfig::default()`), so — mirroring the
2530// `memory.vacuum` regression test in khive-pack-memory's `prune.rs` — this
2531// drives the underlying mechanism directly at the `SqlBridge` level: the same
2532// `execute_script_top_level("PRAGMA wal_checkpoint(TRUNCATE);")` call that
2533// `checkpoint_wal` makes, over a `PoolConfig { write_queue_enabled: true, .. }`
2534// literal (no env var mutation, no cross-test race).
2535#[cfg(test)]
2536mod checkpoint_wal_write_queue_tests {
2537    #[tokio::test]
2538    async fn wal_checkpoint_truncate_succeeds_with_write_queue_enabled() {
2539        let dir = tempfile::tempdir().expect("tempdir");
2540        let db_path = dir.path().join("vcs-checkpoint-write-queue.db");
2541        let pool_cfg = khive_db::PoolConfig {
2542            path: Some(db_path),
2543            write_queue_enabled: true,
2544            ..khive_db::PoolConfig::default()
2545        };
2546        let pool = std::sync::Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
2547        {
2548            let mut writer = pool.writer().expect("writer");
2549            khive_db::run_migrations(writer.conn_mut()).expect("migrations");
2550        }
2551        assert!(
2552            pool.writer_task_handle().unwrap().is_some(),
2553            "writer task must be spawned with the flag on for a file-backed pool"
2554        );
2555
2556        let sql: std::sync::Arc<dyn khive_storage::SqlAccess> =
2557            std::sync::Arc::new(khive_db::SqlBridge::new(std::sync::Arc::clone(&pool), true));
2558
2559        let mut writer = sql.writer().await.expect("writer handle");
2560        let result = writer
2561            .execute_script_top_level("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
2562            .await;
2563
2564        assert!(
2565            result.is_ok(),
2566            "PRAGMA wal_checkpoint(TRUNCATE) via execute_script_top_level must succeed under \
2567             KHIVE_WRITE_QUEUE (no BEGIN IMMEDIATE wrap); got {result:?}"
2568        );
2569    }
2570
2571    /// Revert-and-confirm-fails companion to the test above. See
2572    /// `docs/api/sync.md#wal-checkpoint-under-the-write-queue`.
2573    #[tokio::test]
2574    async fn wal_checkpoint_truncate_via_plain_execute_script_fails_with_write_queue_enabled() {
2575        let dir = tempfile::tempdir().expect("tempdir");
2576        let db_path = dir.path().join("vcs-checkpoint-write-queue-regression.db");
2577        let pool_cfg = khive_db::PoolConfig {
2578            path: Some(db_path),
2579            write_queue_enabled: true,
2580            ..khive_db::PoolConfig::default()
2581        };
2582        let pool = std::sync::Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
2583        {
2584            let mut writer = pool.writer().expect("writer");
2585            khive_db::run_migrations(writer.conn_mut()).expect("migrations");
2586        }
2587
2588        let sql: std::sync::Arc<dyn khive_storage::SqlAccess> =
2589            std::sync::Arc::new(khive_db::SqlBridge::new(std::sync::Arc::clone(&pool), true));
2590
2591        let mut writer = sql.writer().await.expect("writer handle");
2592        let result = writer
2593            .execute_script("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
2594            .await;
2595
2596        assert!(
2597            result.is_err(),
2598            "PRAGMA wal_checkpoint(TRUNCATE) via plain execute_script must FAIL under \
2599             KHIVE_WRITE_QUEUE (it wraps in BEGIN IMMEDIATE, and SQLite rejects a WAL \
2600             checkpoint inside an open transaction); got {result:?} — if this now passes, \
2601             the WriterTask no longer wraps execute_script in a transaction and this whole \
2602             regression class needs re-auditing"
2603        );
2604    }
2605}