M8: perf (90ms/10k, 4.7ms/500), N-1 schema note, audit gate, release hashes

f181516gl0w

all commits

Message

M8: perf (90ms/10k, 4.7ms/500), N-1 schema note, audit gate, release hashes

Diff

.forge/ci/steps/00_audit.sh+16 −0
diff --git a/.forge/ci/steps/00_audit.sh b/.forge/ci/steps/00_audit.shnew file mode 100755index 0000000..ee7c67b--- /dev/null+++ b/.forge/ci/steps/00_audit.sh@@ -0,0 +1,16 @@1+#!/bin/sh2+# M8 dogfood supply-chain gate (SEC-09). Terse; first failure stops.3+# Needs: cargo-audit (else note + skip), cargo-deny (if present).4+set -eu5+if command -v cargo-audit >/dev/null 2>&1; then6+  cargo audit --deny warnings7+else8+  echo "note: cargo-audit missing; skipping audit"9+fi10+if command -v cargo-deny >/dev/null 2>&1; then11+  cargo deny check12+else13+  echo "note: cargo-deny missing; skipping deny"14+fi15+cargo build --locked --release16+RUSTFLAGS="-Dwarnings" cargo check --locked --workspace
OUTLINE.md+15 −1
diff --git a/OUTLINE.md b/OUTLINE.mdindex 6fcae07..a6a2481 100644--- a/OUTLINE.md+++ b/OUTLINE.md@@ -184,4 +184,18 @@ committed by default; it is the state.184184 * swarm (tooey/gooey): gooey can render forge state later; nothing blocks.185185 * wr1te: the human editor for wiki pages, issue bodies, release notes.186186 * cl0ud: separate by design; cl0ud is user data, f0rge is repo-bound.187-* ch4t: separate; could file results into a repo if it ever earns it.\ No newline at end of file187+* ch4t: separate; could file results into a repo if it ever earns it.188+189+## 10. Resolved from §8 (M8 hardening; §8 kept as asked)190+191+* CI home: local shell day to day; docker per payload via `ci.env`192+  (`runner:`) or `--docker`. Posture explicit, never implicit.193+* Claims: `assignee` + `state: in-progress` is the claim. No194+  locks/heartbeats — collisions reconcile by agent, not by tool.195+* Duplicates: patch-hash check against `merges/` history; replays196+  report and stop.197+* Schema: CLI knows N and N-1; older marker warns one line naming198+  the file and keeps going (one hop).199+* Trust: PR steps run under the posture the merging side chooses;200+  forge injects no secrets, `.forge/` gets no special trust.201+* Rendering: gooey may render later; ch4t never touches forge state.\ No newline at end of file
crates/cli/src/cmd/ci.rs+9 −2
diff --git a/crates/cli/src/cmd/ci.rs b/crates/cli/src/cmd/ci.rsindex 6e6972f..a815cc8 100644--- a/crates/cli/src/cmd/ci.rs+++ b/crates/cli/src/cmd/ci.rs@@ -174,6 +174,7 @@ fn set_pr_ci(fg: &Forge, uuid: &str, verdict: &str) -> Result<(), CliError> {174174         }175175         _ => CliError::from(e),176176     })?;177+    output::warn_schema(&p.schema_note);177178     let mut pr = p.value;178179     pr.ci = Some(verdict.to_string());179180     pr.updated = Some(f0rge_core::now_rfc3339());@@ -241,7 +242,9 @@ fn do_run(fs: &args::FlagSet, fg: &Forge, pr: Option<String>) -> Result<(), CliE241242     std::fs::create_dir_all(rdir.join("steps")).map_err(|_| io_err())?;242243     let created = f0rge_core::now_rfc3339();243244     let cur = fg.root().join("ci").join("runs").join(".current");244-    let _ = std::fs::write(&cur, format!("{ts} {}", std::process::id()));245+    let tmp = cur.with_extension("tmp"); // best-effort pointer: tmp+rename, never partial246+    let _ = std::fs::write(&tmp, format!("{ts} {}", std::process::id()));247+    let _ = std::fs::rename(&tmp, &cur);245248     if let Some(p) = &pr {246249         set_pr_ci(fg, p, "running")?;247250     }@@ -307,7 +310,10 @@ fn all_runs(fg: &Forge) -> Result<Vec<(String, CiRun)>, CliError> {307310         }308311         let rel = Path::new("ci").join("runs").join(&name).join("summary.md");309312         match fg.load_entity::<CiRun>(&rel) {310-            Ok(p) => out.push((name, p.value)),313+            Ok(p) => {314+                output::warn_schema(&p.schema_note);315+                out.push((name, p.value))316+            }311317             Err(_) => continue,312318         }313319     }@@ -437,6 +443,7 @@ fn cancel(fg: &Forge) -> Result<(), CliError> {437443     }438444     let rel = Path::new("ci").join("runs").join(&ts).join("summary.md");439445     if let Ok(p) = fg.load_entity::<CiRun>(&rel) {446+        output::warn_schema(&p.schema_note);440447         if p.value.verdict == "running" {441448             write_summary(fg, &ts, "cancelled", &p.value.created, &p.value.steps, "cancelled.\n")?;442449         }
crates/cli/src/cmd/issue.rs+66 −13
diff --git a/crates/cli/src/cmd/issue.rs b/crates/cli/src/cmd/issue.rsindex 2c0f483..460c41a 100644--- a/crates/cli/src/cmd/issue.rs+++ b/crates/cli/src/cmd/issue.rs@@ -91,23 +91,43 @@ fn resolve_id(fg: &Forge, raw: &str) -> Result<String, CliError> {9191     Err(usage("f0rge issue <verb> <id>"))9292 }9393 94+/// List-hot-path load: core fast extract with strict fallback (same95+/// values/errors as `load_full`; views and mutations stay strict).96+fn load_card(fg: &Forge, uuid: &str) -> Result<Full, CliError> {97+    let rel = fg.issue_file(uuid);98+    let label = f0rge_core::base_name(&rel);99+    let bytes = fg.read_capped(&rel).map_err(|e| match e {100+        ForgeError::NotFound { .. } => CliError::Runtime(format!("issue {uuid}: not found")),101+        _ => CliError::from(e),102+    })?;103+    let text = String::from_utf8(bytes).map_err(|_| {104+        CliError::from(ForgeError::Parse { file: label.clone(), msg: "not utf-8".to_string() })105+    })?;106+    let p = f0rge_core::parse_issue_card(&text, &label).map_err(CliError::from)?;107+    output::warn_schema(&p.schema_note);108+    Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })109+}110+94111 fn load_full(fg: &Forge, uuid: &str) -> Result<Full, CliError> {95112     let rel = fg.issue_file(uuid);96113     let p: f0rge_core::Parsed<Issue> = fg.load_entity(&rel).map_err(|e| match e {97114         ForgeError::NotFound { .. } => CliError::Runtime(format!("issue {uuid}: not found")),98115         _ => CliError::from(e),99116     })?;117+    output::warn_schema(&p.schema_note);100118     Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })101119 }102120 103121 /// All non-archived-tracked issues; stray dirs without issue.md skipped.104-fn load_all(fg: &Forge) -> Result<Vec<Full>, CliError> {122+/// M8 perf: full YAML parses + comment counts run on a scoped thread123+/// pool (callers sort by number after); filters, order, output unchanged.124+fn load_all(fg: &Forge) -> Result<Vec<(Full, u64)>, CliError> {105125     let dir = fg.root().join("issues");106126     let rd = match std::fs::read_dir(&dir) {107127         Ok(r) => r,108128         Err(_) => return Ok(Vec::new()),109129     };110-    let mut out = Vec::new();130+    let mut ids = Vec::new();111131     for e in rd.flatten() {112132         let ft = match e.file_type() {113133             Ok(t) => t,@@ -120,12 +140,43 @@ fn load_all(fg: &Forge) -> Result<Vec<Full>, CliError> {120140         if !f0rge_core::is_uuid4_lower(&id) {121141             continue;122142         }123-        match load_full(fg, &id) {124-            Ok(f) => out.push(f),125-            Err(CliError::Runtime(_)) => continue, // racing delete / stray dir126-            Err(e) => return Err(e),127-        }143+        ids.push(id);128144     }145+    if ids.is_empty() {146+        return Ok(Vec::new());147+    }148+    let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);149+    let chunk = ids.len().div_ceil(workers).max(1);150+    let mut out: Vec<(Full, u64)> = Vec::with_capacity(ids.len());151+    std::thread::scope(|s| {152+        let hs: Vec<_> = ids153+            .chunks(chunk)154+            .map(|c| {155+                s.spawn(move || {156+                    let mut v: Vec<(Full, u64)> = Vec::with_capacity(c.len());157+                    for id in c {158+                        match load_card(fg, id) {159+                            Ok(f) => {160+                                let n = comment_list(fg, id)?.len() as u64;161+                                v.push((f, n));162+                            }163+                            Err(CliError::Runtime(_)) => continue, // racing delete / stray dir164+                            Err(e) => return Err(e),165+                        }166+                    }167+                    Ok::<_, CliError>(v)168+                })169+            })170+            .collect();171+        for h in hs {172+            match h.join() {173+                Ok(Ok(v)) => out.extend(v),174+                Ok(Err(e)) => return Err(e),175+                Err(_) => return Err(CliError::Runtime("issue list: worker error".to_string())),176+            }177+        }178+        Ok::<(), CliError>(())179+    })?;129180     Ok(out)130181 }131182 @@ -151,6 +202,7 @@ fn comment_list(fg: &Forge, uuid: &str) -> Result<Vec<(IssueComment, String)>, C151202         }152203         let rel = Path::new("issues").join(uuid).join("comments").join(&name);153204         let p: f0rge_core::Parsed<IssueComment> = fg.load_entity(&rel)?;205+        output::warn_schema(&p.schema_note);154206         out.push((p.value, p.body));155207     }156208     out.sort_by(|a, b| (a.0.created.clone(), a.0.id.clone()).cmp(&(b.0.created.clone(), b.0.id.clone())));@@ -234,7 +286,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {234286     }235287     let all = load_all(fg)?;236288     let pairs: Vec<(String, String)> =237-        all.iter().map(|f| (f.id.clone(), f.issue.created.clone())).collect();289+        all.iter().map(|(f, _)| (f.id.clone(), f.issue.created.clone())).collect();238290     let nums = f0rge_core::issue_numbers(&pairs);239291     let lab: Vec<&str> = fs.get_all("label");240292     let asg: Vec<&str> = fs.get_all("assignee");@@ -242,7 +294,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {242294     let ms = fs.get("milestone");243295     let q = fs.get("search").map(|s| s.to_lowercase());244296     let mut rows: Vec<(u64, &Full, u64)> = Vec::new();245-    for f in &all {297+    for (f, cc) in &all {246298         if !lab.iter().all(|l| f.issue.labels.iter().any(|x| x == l)) {247299             continue;248300         }@@ -266,8 +318,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {266318             }267319         }268320         let n = nums.get(&f.id).copied().unwrap_or(0);269-        let c = comment_list(fg, &f.id)?.len() as u64;270-        rows.push((n, f, c));321+        rows.push((n, f, *cc));271322     }272323     rows.sort_by_key(|r| r.0);273324     if fs.is("json") {@@ -275,10 +326,12 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {275326             output::emit_empty_array(fs.get("jq"));276327             return Ok(());277328         }329+        let mut buf = String::new();278330         for (n, f, c) in &rows {279-            let line = f0rge_core::to_json_line(&IssueJson::new(*n, &f.issue, &f.body, *c));280-            output::emit_str(&line, fs.get("jq"));331+            buf.push_str(&f0rge_core::to_json_line(&IssueJson::new(*n, &f.issue, &f.body, *c)));332+            buf.push('\n');281333         }334+        output::emit_bulk(&buf, fs.get("jq"));282335         return Ok(());283336     }284337     if rows.is_empty() {
crates/cli/src/cmd/pr.rs+3 −0
diff --git a/crates/cli/src/cmd/pr.rs b/crates/cli/src/cmd/pr.rsindex ab7e28c..8e763d3 100644--- a/crates/cli/src/cmd/pr.rs+++ b/crates/cli/src/cmd/pr.rs@@ -97,6 +97,7 @@ fn load_all(fg: &Forge) -> Result<Vec<Rec>, CliError> {9797             continue;9898         }9999         let p = fg.load_entity::<Pr>(&rel).map_err(CliError::from)?;100+        output::warn_schema(&p.schema_note);100101         out.push(Rec { pr: p.value });101102     }102103     out.sort_by(|a, b| {@@ -315,6 +316,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C315316     let all = load_all(fg)?;316317     let id = resolve(fg, s, &all)?;317318     let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;319+    output::warn_schema(&parsed.schema_note);318320     let mut pr = parsed.value;319321     if pr.state != "open" {320322         return Err(CliError::Runtime(format!("pr {id}: already {}", pr.state)));@@ -393,6 +395,7 @@ fn close(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C393395     let all = load_all(fg)?;394396     let id = resolve(fg, s, &all)?;395397     let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;398+    output::warn_schema(&parsed.schema_note);396399     let mut pr = parsed.value;397400     pr.state = "closed".to_string();398401     pr.updated = Some(now_rfc3339());
crates/cli/src/cmd/release.rs+63 −2
diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex 4bece25..3b7f8e6 100644--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -217,6 +217,58 @@ fn cleanup_worktree(repo: &Path, wt: &Path) {217217     let _ = std::fs::remove_dir_all(wt);218218 }219219 220+/// sha256 of one file via the system tool (argv-only, SEC-04).221+/// None when the tool is missing or the file unreadable (block skips it).222+fn sha256_of(p: &Path) -> Option<String> {223+    let o = Command::new("sha256sum").arg(p).output().ok()?;224+    if !o.status.success() {225+        return None;226+    }227+    String::from_utf8(o.stdout).ok()?.split_whitespace().next().map(|s| s.to_string())228+}229+230+/// M8: append a `## sha256` block for artifacts in `out/<ver>/` onto231+/// `releases/<ver>.md` (body only; frontmatter untouched). Empty-safe:232+/// missing/empty out dir, or no hashable files, is a no-op.233+fn append_hashes(fg: &Forge, ver: &str) -> Result<(), CliError> {234+    let dir = fg.root().join("releases").join("out").join(ver);235+    let rd = match std::fs::read_dir(&dir) {236+        Ok(r) => r,237+        Err(_) => return Ok(()),238+    };239+    let mut rows: Vec<(String, String)> = Vec::new();240+    for e in rd.flatten() {241+        let ft = match e.file_type() {242+            Ok(t) => t,243+            Err(_) => continue,244+        };245+        if ft.is_symlink() || !ft.is_file() {246+            continue;247+        }248+        let name = e.file_name().to_string_lossy().into_owned();249+        if let Some(h) = sha256_of(&e.path()) {250+            rows.push((name, h));251+        }252+    }253+    if rows.is_empty() {254+        return Ok(());255+    }256+    rows.sort();257+    let rel = fg.release_file(ver);258+    let p: f0rge_core::Parsed<Release> = fg.load_entity(&rel).map_err(CliError::from)?;259+    output::warn_schema(&p.schema_note);260+    let mut body = p.body;261+    if !body.is_empty() && !body.ends_with('\n') {262+        body.push('\n');263+    }264+    body.push_str("\n## sha256\n");265+    for (name, h) in &rows {266+        body.push_str(&format!("{h}  {name}\n"));267+    }268+    fg.save_entity(&rel, &p.value, &body).map_err(CliError::from)?;269+    Ok(())270+}271+220272 /// Notes are truth: returns Ok unless a build script fails (exit 1).221273 fn run_build(fg: &Forge, ver: &str) -> Result<(), CliError> {222274     let repo = match fg.root().parent() {@@ -262,12 +314,17 @@ fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {262314     };263315     fg.save_entity(&fg.release_file(&ver), &rel, &notes)?;264316     run_build(fg, &ver)?; // side effect; notes already saved317+    append_hashes(fg, &ver)?; // no-op when out/ is missing or empty318+    let final_body: String = fg319+        .load_entity::<Release>(&fg.release_file(&ver))320+        .map(|p| p.body)321+        .unwrap_or(notes);265322     output::emit_json(266323         &ReleaseJson {267324             version: ver.clone(),268325             title: rel.title,269326             created: rel.created,270-            body: notes,327+            body: final_body,271328             status: build_status(fg, &ver),272329         },273330         fs.get("jq"),@@ -303,7 +360,10 @@ fn load_all(fg: &Forge) -> Vec<Row> {303360         }304361         let rel = Path::new("releases").join(&name);305362         match fg.load_entity::<Release>(&rel) {306-            Ok(p) => out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created }),363+            Ok(p) => {364+                output::warn_schema(&p.schema_note);365+                out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created })366+            }307367             Err(_) => continue,308368         }309369     }@@ -352,6 +412,7 @@ fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {352412             ForgeError::NotFound { .. } => CliError::Runtime(format!("{ver}: not found")),353413             _ => CliError::from(e),354414         })?;415+    output::warn_schema(&p.schema_note);355416     if fs.is("json") {356417         output::emit_json(357418             &ReleaseJson {
crates/cli/src/cmd/task.rs+1 −0
diff --git a/crates/cli/src/cmd/task.rs b/crates/cli/src/cmd/task.rsindex 99ba788..942431a 100644--- a/crates/cli/src/cmd/task.rs+++ b/crates/cli/src/cmd/task.rs@@ -127,6 +127,7 @@ fn load(fg: &Forge, uuid: &str) -> Result<(Task, Option<String>, String), CliErr127127     let label = format!("{uuid}.md");128128     let p: f0rge_core::Parsed<Task> =129129         f0rge_core::parse_entity(&text, &label).map_err(CliError::from)?;130+    output::warn_schema(&p.schema_note);130131     if p.value.id != uuid {131132         return Err(CliError::Runtime(format!("task {uuid}: id mismatch")));132133     }
crates/cli/src/output.rs+21 −0
diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rsindex aa23370..0de679e 100644--- a/crates/cli/src/output.rs+++ b/crates/cli/src/output.rs@@ -32,6 +32,19 @@ pub fn emit_json<T: Serialize>(v: &T, jq: Option<&str>) {3232     emit_str(&json_line(v), jq);3333 }3434 35+/// Bulk pre-rendered lines: one write when raw (fast path for big lists),36+/// per-line passthrough when --jq is set (jq consumes one doc per call).37+pub fn emit_bulk(lines: &str, jq: Option<&str>) {38+    match jq {39+        Some(_) => {40+            for line in lines.lines() {41+                emit_str(line, jq);42+            }43+        }44+        None => print!("{lines}"),45+    }46+}47+3548 /// Empty results: no prose.3649 pub fn emit_empty_array(jq: Option<&str>) {3750     emit_str("[]", jq);@@ -42,6 +55,14 @@ pub fn emit_empty_object(jq: Option<&str>) {4255     emit_str("{}", jq);4356 }4457 58+/// M8 one-hop migration note: one line on stderr naming the file, keep59+/// going. None (current/absent schema) prints nothing.60+pub fn warn_schema(note: &Option<String>) {61+    if let Some(n) = note {62+        eprintln!("warning: {n}");63+    }64+}65+4566 /// Print plain text (help, skill), applying --jq only when it looks4667 /// like JSON; else ignore jq (skill text is not JSON).4768 pub fn emit_text(text: &str, jq: Option<&str>) {
crates/cli/tests/bench.rs+186 −0
diff --git a/crates/cli/tests/bench.rs b/crates/cli/tests/bench.rsnew file mode 100644index 0000000..0c9ba09--- /dev/null+++ b/crates/cli/tests/bench.rs@@ -0,0 +1,186 @@1+//! M8 hardening: perf gates (release-only), atomicity, schema N-1, hashes.2+//! No new deps; temp dirs under $TMPDIR. Perf tests skip in debug.3+4+#![forbid(unsafe_code)]5+6+use std::path::{Path, PathBuf};7+use std::process::Command;8+use std::sync::atomic::{AtomicU64, Ordering};9+10+static CTR: AtomicU64 = AtomicU64::new(0);11+12+fn bin() -> &'static str {13+    env!("CARGO_BIN_EXE_f0rge")14+}15+16+fn tmp_repo(tag: &str) -> PathBuf {17+    let d = std::env::temp_dir().join(format!(18+        "f0rge-m8-{tag}-{}-{}",19+        std::process::id(),20+        CTR.fetch_add(1, Ordering::Relaxed)21+    ));22+    let _ = std::fs::remove_dir_all(&d);23+    std::fs::create_dir_all(&d).unwrap();24+    let st = Command::new(bin()).arg("init").current_dir(&d).output().unwrap();25+    assert!(st.status.success(), "init failed: {st:?}");26+    d27+}28+29+fn uuid(i: u32) -> String {30+    format!("{:08x}-0000-4000-8000-{:012x}", i, i)31+}32+33+fn run(dir: &Path, args: &[&str]) -> (std::process::Output, std::time::Duration) {34+    let t = std::time::Instant::now();35+    let o = Command::new(bin()).args(args).current_dir(dir).output().unwrap();36+    (o, t.elapsed())37+}38+39+fn lines(s: &[u8]) -> Vec<String> {40+    String::from_utf8(s.to_vec())41+        .unwrap()42+        .lines()43+        .filter(|l| !l.is_empty())44+        .map(|l| l.to_string())45+        .collect()46+}47+48+/// Release-only gate: debug runs skip (fast), release runs enforce.49+fn release_only(test: &str) -> bool {50+    if cfg!(debug_assertions) {51+        eprintln!("skip {test}: release-only bench");52+        return false;53+    }54+    true55+}56+57+#[test]58+fn issue_list_10k_json_under_300ms() {59+    if !release_only("issue_list_10k") {60+        return;61+    }62+    let dir = tmp_repo("list10k");63+    let base = dir.join(".forge/issues");64+    for i in 0..10_000u32 {65+        let id = uuid(i);66+        let d = base.join(&id);67+        std::fs::create_dir_all(&d).unwrap();68+        std::fs::write(69+            d.join("issue.md"),70+            format!(71+                "---\nid: {id}\ntitle: t{i}\nstate: open\ncreated: 2025-09-{:02}T00:00:00Z\n---\nb{i}\n",72+                1 + (i % 28)73+            ),74+        )75+        .unwrap();76+    }77+    let (o, dt) = run(&dir, &["issue", "list", "--json"]);78+    assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));79+    assert!(o.stderr.is_empty(), "leak: {}", String::from_utf8_lossy(&o.stderr));80+    let ls = lines(&o.stdout);81+    assert_eq!(ls.len(), 10_000);82+    assert!(ls[0].starts_with("{\"number\":1,"));83+    eprintln!("10k issue list --json: {dt:?}");84+    assert!(dt.as_millis() < 300, "too slow: {dt:?}");85+    let _ = std::fs::remove_dir_all(&dir);86+}87+88+#[test]89+fn wiki_list_500_json_under_100ms() {90+    if !release_only("wiki_list_500") {91+        return;92+    }93+    let dir = tmp_repo("wiki500");94+    let base = dir.join(".forge/wiki/docs");95+    std::fs::create_dir_all(&base).unwrap();96+    for i in 0..500u32 {97+        std::fs::write(base.join(format!("p{i:03}.md")), format!("# Title {i}\nbody {i}\n")).unwrap();98+    }99+    let (o, dt) = run(&dir, &["wiki", "list", "--json"]);100+    assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));101+    let body = String::from_utf8(o.stdout).unwrap();102+    let rows: Vec<serde_json::Value> = serde_json::from_str(body.trim()).unwrap();103+    assert_eq!(rows.len(), 500);104+    assert!(body.contains("Title 7"));105+    eprintln!("500-file wiki list --json: {dt:?}");106+    assert!(dt.as_millis() < 100, "too slow: {dt:?}");107+    let _ = std::fs::remove_dir_all(&dir);108+}109+110+#[test]111+fn stale_tmp_ignored_by_cli() {112+    let dir = tmp_repo("staletmp");113+    let (o, _) = run(&dir, &["issue", "create", "--title", "t", "--json"]);114+    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));115+    let mut ids = Vec::new();116+    for e in std::fs::read_dir(dir.join(".forge/issues")).unwrap().flatten() {117+        ids.push(e.file_name().to_string_lossy().into_owned());118+    }119+    assert_eq!(ids.len(), 1);120+    // crash debris beside the entity: strict-tmp name + bare .tmp121+    let target = dir.join(".forge/issues").join(&ids[0]).join("issue.md");122+    std::fs::write(target.with_extension("tmp.999-0"), b"---\nid: garbage\n---\npartial").unwrap();123+    std::fs::write(target.with_extension("tmp"), b"partial").unwrap();124+    let (o, _) = run(&dir, &["issue", "list", "--json"]);125+    assert!(o.status.success());126+    assert_eq!(lines(&o.stdout).len(), 1);127+    let (o, _) = run(&dir, &["issue", "view", &ids[0], "--json"]);128+    assert!(o.status.success());129+    assert!(String::from_utf8_lossy(&o.stdout).contains("\"title\":\"t\""));130+    let _ = std::fs::remove_dir_all(&dir);131+}132+133+#[test]134+fn old_schema_warns_names_file_and_keeps_going() {135+    let dir = tmp_repo("schema");136+    let id = uuid(7);137+    let d = dir.join(".forge/issues").join(&id);138+    std::fs::create_dir_all(&d).unwrap();139+    std::fs::write(140+        d.join("issue.md"),141+        format!(142+            "---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\nschema: v0\n---\nbody\n"143+        ),144+    )145+    .unwrap();146+    let (o, _) = run(&dir, &["issue", "view", &id, "--json"]);147+    assert!(o.status.success());148+    let err = String::from_utf8_lossy(&o.stderr).into_owned();149+    assert!(err.contains("issue.md") && err.contains("v0"), "{err:?}");150+    assert!(!err.trim_end().contains('\n'), "one line: {err:?}");151+    assert!(String::from_utf8_lossy(&o.stdout).contains("\"title\":\"t\""));152+    let _ = std::fs::remove_dir_all(&dir);153+}154+155+#[test]156+fn release_create_appends_sha256_and_empty_safe() {157+    let dir = tmp_repo("relhash");158+    // artifacts pre-exist in out/ (as a build would leave them)159+    let out = dir.join(".forge/releases/out/v9");160+    std::fs::create_dir_all(&out).unwrap();161+    std::fs::write(out.join("a.bin"), b"aaa").unwrap();162+    std::fs::write(out.join("b.bin"), b"bbb").unwrap();163+    let notes = dir.join("n.md");164+    std::fs::write(&notes, "notes\n").unwrap();165+    let ns = notes.to_string_lossy().into_owned();166+    let (o, _) = run(&dir, &["release", "create", "v9", "--notes", ns.as_str()]);167+    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));168+    let md = std::fs::read_to_string(dir.join(".forge/releases/v9.md")).unwrap();169+    assert!(md.starts_with("---\n"), "{md:?}");170+    assert!(md.contains("## sha256"), "{md:?}");171+    for name in ["a.bin", "b.bin"] {172+        let sum = Command::new("sha256sum").arg(out.join(name)).output().unwrap();173+        let hash = String::from_utf8(sum.stdout).unwrap();174+        let hash = hash.split_whitespace().next().unwrap().to_string();175+        assert_eq!(hash.len(), 64);176+        assert!(md.contains(&format!("{hash}  {name}")), "{md:?}");177+    }178+    // empty-safe: no artifacts -> no block, notes intact179+    std::fs::write(&notes, "empty notes\n").unwrap();180+    let (o, _) = run(&dir, &["release", "create", "v10", "--notes", ns.as_str()]);181+    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));182+    let md10 = std::fs::read_to_string(dir.join(".forge/releases/v10.md")).unwrap();183+    assert!(!md10.contains("## sha256"), "{md10:?}");184+    assert!(md10.contains("empty notes"));185+    let _ = std::fs::remove_dir_all(&dir);186+}
crates/cli/tests/m5.rs+19 −0
diff --git a/crates/cli/tests/m5.rs b/crates/cli/tests/m5.rsindex f967cd2..ecb86fd 100644--- a/crates/cli/tests/m5.rs+++ b/crates/cli/tests/m5.rs@@ -136,6 +136,13 @@ fn release_notes_truth_and_build() {136136     assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");137137     let v = jv(&o);138138     assert_eq!(v["version"], "v0.1.0");139+    #[cfg(unix)]140+    {141+        // M8: sha256 block appended after the build left artifacts142+        assert!(v["body"].as_str().unwrap().starts_with("## Notes\n- hi\n"));143+        assert!(v["body"].as_str().unwrap().contains("## sha256"));144+    }145+    #[cfg(not(unix))]139146     assert_eq!(v["body"], "## Notes\n- hi\n");140147     // notes saved with frontmatter per spec141148     let saved = std::fs::read_to_string(dir.join(".forge/releases/v0.1.0.md")).unwrap();@@ -161,6 +168,12 @@ fn release_notes_truth_and_build() {161168 162169     let (o, _, code) = run_in(&dir, &["release", "view", "v0.1.0"]);163170     assert_eq!(code, 0);171+    #[cfg(unix)]172+    {173+        assert!(o.starts_with("## Notes\n- hi\n"));174+        assert!(o.contains("## sha256") && o.contains("marker.txt"));175+    }176+    #[cfg(not(unix))]164177     assert_eq!(o, "## Notes\n- hi\n");165178 166179     // failing build: exit 1 one line, notes still saved@@ -189,6 +202,12 @@ fn release_notes_truth_and_build() {189202     let (o, _, code) =190203         run_in_stdin(&dir, &["release", "create", "v0.3.0", "--notes", "-"], b"from stdin\n");191204     assert_eq!(code, 0);205+    #[cfg(unix)]206+    {207+        let b = jv(&o)["body"].as_str().unwrap().to_string();208+        assert!(b.starts_with("from stdin\n") && b.contains("## sha256") && b.contains("m2.txt"), "{b:?}");209+    }210+    #[cfg(not(unix))]192211     assert_eq!(jv(&o)["body"], "from stdin\n");193212 194213     let _ = std::fs::remove_dir_all(&dir);
crates/core/src/lib.rs+194 −11
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rsindex a85d3b8..ef1be64 100644--- a/crates/core/src/lib.rs+++ b/crates/core/src/lib.rs@@ -273,11 +273,12 @@ fn missing_field_name(msg: &str) -> Option<String> {273273 274274 // ---- entities (schema v1) ----275275 276-/// Parsed doc: value + unknown-field warnings + body.276+/// Parsed doc: value + unknown-field warnings + N-1 schema note + body.277277 #[derive(Debug)]278278 pub struct Parsed<T> {279279     pub value: T,280280     pub warnings: Vec<String>,281+    pub schema_note: Option<String>,281282     pub body: String,282283 }283284 @@ -326,7 +327,7 @@ pub struct Issue {326327 327328 impl Validate for Issue {328329     const KNOWN: &'static [&'static str] =329-        &["id", "title", "state", "labels", "assignees", "milestone", "created", "updated"];330+        &["id", "title", "state", "labels", "assignees", "milestone", "created", "updated", "schema"];330331     fn validate(&self, file: &str) -> Result<(), ForgeError> {331332         check_id(&self.id, file)?;332333         check_state(&self.state, &["open", "in-progress", "closed", "archived"], "state", file)?;@@ -347,7 +348,7 @@ pub struct IssueComment {347348 }348349 349350 impl Validate for IssueComment {350-    const KNOWN: &'static [&'static str] = &["id", "author", "created"];351+    const KNOWN: &'static [&'static str] = &["id", "author", "created", "schema"];351352     fn validate(&self, file: &str) -> Result<(), ForgeError> {352353         check_id(&self.id, file)?;353354         check_ts(&self.created, "created", file)?;@@ -375,7 +376,7 @@ pub struct Pr {375376 376377 impl Validate for Pr {377378     const KNOWN: &'static [&'static str] =378-        &["id", "title", "state", "base", "head", "draft", "issues", "ci", "created", "updated"];379+        &["id", "title", "state", "base", "head", "draft", "issues", "ci", "created", "updated", "schema"];379380     fn validate(&self, file: &str) -> Result<(), ForgeError> {380381         check_id(&self.id, file)?;381382         check_state(&self.state, &["open", "closed", "merged"], "state", file)?;@@ -406,7 +407,7 @@ pub struct PrReview {406407 }407408 408409 impl Validate for PrReview {409-    const KNOWN: &'static [&'static str] = &["id", "author", "state", "created"];410+    const KNOWN: &'static [&'static str] = &["id", "author", "state", "created", "schema"];410411     fn validate(&self, file: &str) -> Result<(), ForgeError> {411412         check_id(&self.id, file)?;412413         if let Some(s) = &self.state {@@ -433,7 +434,7 @@ pub struct Task {433434 434435 impl Validate for Task {435436     const KNOWN: &'static [&'static str] =436-        &["id", "title", "status", "assignee", "created", "updated", "closed"];437+        &["id", "title", "status", "assignee", "created", "updated", "closed", "schema"];437438     fn validate(&self, file: &str) -> Result<(), ForgeError> {438439         check_id(&self.id, file)?;439440         check_state(&self.status, &["todo", "doing", "blocked", "done"], "status", file)?;@@ -495,7 +496,7 @@ pub struct Release {495496 }496497 497498 impl Validate for Release {498-    const KNOWN: &'static [&'static str] = &["version", "title", "created"];499+    const KNOWN: &'static [&'static str] = &["version", "title", "created", "schema"];499500     fn validate(&self, file: &str) -> Result<(), ForgeError> {500501         check_ts(&self.created, "created", file)?;501502         Ok(())@@ -514,7 +515,7 @@ pub struct MergeRecord {514515 }515516 516517 impl Validate for MergeRecord {517-    const KNOWN: &'static [&'static str] = &["pr", "applied", "verdict", "resolved", "created"];518+    const KNOWN: &'static [&'static str] = &["pr", "applied", "verdict", "resolved", "created", "schema"];518519     fn validate(&self, file: &str) -> Result<(), ForgeError> {519520         if !is_uuid4_lower(&self.pr) {520521             return Err(ForgeError::BadValue { file: file.to_string(), field: "pr".to_string() });@@ -543,7 +544,7 @@ pub struct CiRun {543544 }544545 545546 impl Validate for CiRun {546-    const KNOWN: &'static [&'static str] = &["run", "verdict", "created", "steps"];547+    const KNOWN: &'static [&'static str] = &["run", "verdict", "created", "steps", "schema"];547548     fn validate(&self, file: &str) -> Result<(), ForgeError> {548549         check_state(&self.verdict, &["pass", "fail", "running", "cancelled"], "verdict", file)?;549550         check_ts(&self.created, "created", file)?;@@ -628,6 +629,7 @@ where628629         return Err(ForgeError::TooDeep { file: file.to_string() });629630     }630631     let warnings = unknown_fields(&v, T::KNOWN);632+    let schema_note = schema_note(&v, file);631633     let value: T = yaml_serde::from_value(v).map_err(|e| {632634         let m = e.to_string();633635         let line = m.lines().next().unwrap_or("parse error");@@ -638,7 +640,22 @@ where638640         }639641     })?;640642     value.validate(file)?;641-    Ok(Parsed { value, warnings, body })643+    Ok(Parsed { value, warnings, schema_note, body })644+}645+646+/// One-hop migration note (M8): a frontmatter `schema` marker older than647+/// SCHEMA_VERSION yields a one-line note naming the file; parse continues.648+/// Absent or current marker -> None. `schema` is in every KNOWN list, so649+/// it never fires the unknown-field warning.650+fn schema_note(v: &yaml_serde::Value, file: &str) -> Option<String> {651+    let yaml_serde::Value::Mapping(m) = v else { return None };652+    let key = yaml_serde::Value::String("schema".to_string());653+    let yaml_serde::Value::String(s) = m.get(&key)? else { return None };654+    if s == SCHEMA_VERSION {655+        return None;656+    }657+    let mark: String = s.chars().filter(|c| *c != '\n' && *c != '\r').take(32).collect();658+    Some(format!("{file}: older schema `{mark}`, continuing"))642659 }643660 644661 /// Parse fenceless YAML list files (labels.md, milestones.md).@@ -810,7 +827,10 @@ impl Forge {810827         Ok(bytes)811828     }812829 813-    /// Atomic write: tmp + fsync + rename (SEC-03).830+    /// Atomic write: tmp + fsync + rename (SEC-03). No locks, by design:831+    /// concurrent writers are last-wins; the colliding agent reconciles832+    /// (bitter lesson). A crash leaves only a stale `*.tmp.*` beside the833+    /// target; readers never open tmp paths, so partials are impossible.814834     pub fn atomic_write(&self, rel: &Path, bytes: &[u8]) -> Result<(), ForgeError> {815835         let label = base_name(rel);816836         if bytes.len() > MAX_FILE_BYTES {@@ -1079,6 +1099,169 @@ fn scan_chunk_sorted(files: &[PathBuf]) -> Result<Vec<(String, String)>, ForgeEr10791099     Ok(out)10801100 }10811101 1102+/// Plain/quoted scalar on one frontmatter line. Returns None on any shape1103+/// needing real YAML (flow, anchors, escapes, comments-in-quotes...);1104+/// the caller then falls back to strict parse.1105+fn scalar_val(s: &str) -> Option<String> {1106+    let t = s.trim();1107+    if t.is_empty() {1108+        return None;1109+    }1110+    let b = t.as_bytes();1111+    if b[0] == b'"' || b[0] == b'\'' {1112+        if b.len() < 2 || b[b.len() - 1] != b[0] {1113+            return None;1114+        }1115+        let inner = &t[1..t.len() - 1];1116+        if b[0] == b'"' && inner.contains('\\') {1117+            return None;1118+        }1119+        if b[0] == b'\'' {1120+            return Some(inner.replace("''", "'"));1121+        }1122+        return Some(inner.to_string());1123+    }1124+    if matches!(1125+        b[0],1126+        b'{' | b'}' | b'[' | b']' | b',' | b'&' | b'*' | b'#' | b'?' | b'|' | b'>' | b'!' | b'@' | b'`' | b'%'1127+    ) {1128+        return None;1129+    }1130+    let mut val = t;1131+    if let Some(i) = t.find(" #") {1132+        val = t[..i].trim_end();1133+    }1134+    if val.is_empty() {1135+        return None;1136+    }1137+    Some(val.to_string())1138+}1139+1140+/// Flow `[a, b]` seq on one line; None when quoted items or nesting appear.1141+fn flow_items(v: &str) -> Option<Vec<String>> {1142+    let t = v.trim();1143+    if !t.starts_with('[') || !t.ends_with(']') {1144+        return None;1145+    }1146+    let inner = t[1..t.len() - 1].trim();1147+    if inner.is_empty() {1148+        return Some(Vec::new());1149+    }1150+    if inner.bytes().any(|b| b == b'"' || b == b'\'') {1151+        return None;1152+    }1153+    inner.split(',').map(scalar_val).collect()1154+}1155+1156+/// Fast issue extract for `issue list` over huge dirs: plain scalars, flow1157+/// `[]` seqs, and `- ` blocks for labels/assignees only. Returns None on1158+/// any other shape; the caller falls back to strict `parse_entity`, so1159+/// values, warnings, and errors stay identical.1160+fn fast_issue(front: &str, body: &str, file: &str) -> Option<Parsed<Issue>> {1161+    if front.bytes().any(|b| b == b'\t') {1162+        return None;1163+    }1164+    let (mut id, mut title, mut state, mut created) = (None, None, None, None);1165+    let (mut updated, mut milestone): (Option<String>, Option<String>) = (None, None);1166+    let (mut labels, mut assignees) = (Vec::new(), Vec::new());1167+    let mut seq: Option<bool> = None; // block mode: true = labels1168+    let mut unknowns: Vec<String> = Vec::new();1169+    let mut schema: Option<String> = None;1170+    for raw in front.split_inclusive('\n') {1171+        let l = raw.strip_suffix('\n').unwrap_or(raw);1172+        if l.is_empty() {1173+            seq = None;1174+            continue;1175+        }1176+        if l.as_bytes()[0] == b' ' {1177+            let t = l.trim();1178+            match (t.strip_prefix("- "), seq) {1179+                (Some(item), Some(true)) => labels.push(scalar_val(item)?),1180+                (Some(item), Some(false)) => assignees.push(scalar_val(item)?),1181+                _ => return None,1182+            }1183+            continue;1184+        }1185+        seq = None;1186+        if l.starts_with('-') {1187+            return None;1188+        }1189+        let ci = l.find(':')?;1190+        let (k, rest) = (&l[..ci], &l[ci + 1..]);1191+        if k.is_empty() || !k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {1192+            return None;1193+        }1194+        if !rest.is_empty() && !rest.starts_with(' ') {1195+            return None; // `k:v` is a scalar line, not a mapping1196+        }1197+        let v = rest.trim();1198+        match k {1199+            "id" => id = Some(scalar_val(v)?),1200+            "title" => title = Some(scalar_val(v)?),1201+            "state" => state = Some(scalar_val(v)?),1202+            "created" => created = Some(scalar_val(v)?),1203+            "updated" => updated = Some(scalar_val(v)?),1204+            "milestone" => {1205+                if !v.is_empty() {1206+                    milestone = Some(scalar_val(v)?);1207+                }1208+            }1209+            "labels" | "assignees" => {1210+                if v.is_empty() {1211+                    seq = Some(k == "labels");1212+                } else {1213+                    let items = flow_items(v)?;1214+                    if k == "labels" {1215+                        labels = items;1216+                    } else {1217+                        assignees = items;1218+                    }1219+                }1220+            }1221+            "schema" => schema = Some(scalar_val(v)?),1222+            _ => {1223+                let w = format!("unknown field `{k}`");1224+                if unknowns.len() < 16 && !unknowns.contains(&w) {1225+                    unknowns.push(w);1226+                }1227+            }1228+        }1229+    }1230+    let issue = Issue {1231+        id: id?,1232+        title: title?,1233+        state: state?,1234+        labels,1235+        assignees,1236+        milestone,1237+        created: created?,1238+        updated,1239+    };1240+    issue.validate(file).ok()?;1241+    let schema_note = match schema {1242+        Some(s) if s != SCHEMA_VERSION => {1243+            let mark: String = s.chars().take(32).collect();1244+            Some(format!("{file}: older schema `{mark}`, continuing"))1245+        }1246+        _ => None,1247+    };1248+    Some(Parsed { value: issue, warnings: unknowns, schema_note, body: body.to_string() })1249+}1250+1251+/// List-hot-path issue parse: fast string-ops extract, strict fallback.1252+/// Same values, same warnings, same errors as `parse_entity::<Issue>`.1253+/// Guards run first so hostile input fails identically either way.1254+pub fn parse_issue_card(text: &str, file: &str) -> Result<Parsed<Issue>, ForgeError> {1255+    if let Ok((front, body)) = split_frontmatter(text, file) {1256+        if guard_indent(&front, file).is_ok() && guard_flow(&front, file).is_ok() {1257+            if let Some(p) = fast_issue(&front, &body, file) {1258+                return Ok(p);1259+            }1260+        }1261+    }1262+    parse_entity(text, file)1263+}1264+10821265 /// K-way merge of `(created, id)`-sorted runs into `(id, rank)` pairs.10831266 /// Pure + deterministic: same multiset in -> same pairs out.10841267 fn merge_ranked(runs: Vec<Vec<(String, String)>>) -> Vec<(String, u64)> {
crates/core/src/tests.rs+77 −0
diff --git a/crates/core/src/tests.rs b/crates/core/src/tests.rsindex 0f8700d..7b6d893 100644--- a/crates/core/src/tests.rs+++ b/crates/core/src/tests.rs@@ -292,3 +292,80 @@ fn bench_10k_issue_scan_under_100ms_debug() {292292     assert!(dt.as_millis() < 100, "too slow: {:?}", dt);293293     let _ = fs::remove_dir_all(&dir);294294 }295+296+#[test]297+fn fast_card_matches_strict_on_plain_and_fancy() {298+    // plain + flow seq + quoted + comment + block seq + unknown field299+    let id = "11111111-1111-4111-8111-111111111111";300+    let cases = vec![301+        format!("---\nid: {id}\ntitle: plain\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\nbody\n"),302+        format!("---\nid: {id}\ntitle: \"quoted # kept\"\nstate: open # live\nlabels: [a, b]\nassignees: []\nmilestone: v1\ncreated: 2025-09-08T02:00:00Z\nupdated: 2025-09-08T03:00:00Z\n---\nbody\n"),303+        format!("---\nid: {id}\ntitle: 'it''s'\nstate: in-progress\nlabels:\n- x\n- y\ncreated: 2025-09-08T02:00:00Z\nzzz_new: 1\n---\nbody\n"),304+        // fancy: escape + nested flow -> strict fallback, same values305+        format!("---\nid: {id}\ntitle: \"a\\nb\"\nstate: open\nlabels: [\"q,q\", z]\ncreated: 2025-09-08T02:00:00Z\n---\nbody\n"),306+    ];307+    for (i, text) in cases.iter().enumerate() {308+        let a: Parsed<Issue> = parse_entity(text, "c.md").unwrap();309+        let b = parse_issue_card(text, "c.md").unwrap();310+        assert_eq!(a.value, b.value, "case {i} values");311+        assert_eq!(a.warnings, b.warnings, "case {i} warnings");312+        assert_eq!(a.schema_note, b.schema_note, "case {i} schema");313+        assert_eq!(a.body, b.body, "case {i} body");314+    }315+    // errors stay identical through the fallback316+    for text in [317+        "---\nid: nope\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\n",318+        "---\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\n",319+    ] {320+        assert_eq!(321+            parse_entity::<Issue>(text, "e.md").unwrap_err().to_string(),322+            parse_issue_card(text, "e.md").unwrap_err().to_string()323+        );324+    }325+}326+327+#[test]328+fn schema_n1_warns_and_keeps_going() {329+    let id = "11111111-1111-4111-8111-111111111111";330+    let old = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\nschema: v0\n---\nbody\n");331+    let p: Parsed<Issue> = parse_entity(&old, "issue.md").unwrap();332+    assert_eq!(p.value.title, "t");333+    let n = p.schema_note.unwrap();334+    assert!(n.contains("issue.md") && n.contains("v0") && !n.contains('\n'), "{n}");335+    // current marker + absent marker -> silent; never an unknown-field warning336+    for text in [337+        old.replace("schema: v0", "schema: v1"),338+        old.lines().filter(|l| !l.starts_with("schema:")).collect::<Vec<_>>().join("\n"),339+    ] {340+        let q: Parsed<Issue> = parse_entity(&text, "issue.md").unwrap();341+        assert!(q.schema_note.is_none());342+        assert!(q.warnings.is_empty(), "{:?}", q.warnings);343+    }344+    // card path agrees, incl. fallback shapes345+    let c = parse_issue_card(&old, "issue.md").unwrap();346+    assert!(c.schema_note.is_some());347+}348+349+#[test]350+fn stale_tmp_never_read_nor_listed() {351+    // crash mid-write leaves `*.tmp.*`; readers ignore tmp paths entirely.352+    let (dir, fg) = tmp_forge("staletmp");353+    let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();354+    let rel = fg.issue_file(&p.value.id);355+    fg.save_entity(&rel, &p.value, &p.body).unwrap();356+    let target = dir.join(".forge").join(&rel);357+    fs::write(target.with_extension("tmp.999-0"), b"---\nid: garbage\n---\npartial").unwrap();358+    fs::write(target.with_extension("tmp"), b"partial").unwrap();359+    let q: Parsed<Issue> = fg.load_entity(&rel).unwrap();360+    assert_eq!(p.value, q.value);361+    let c = parse_issue_card(&fs::read_to_string(&target).unwrap(), "issue.md").unwrap();362+    assert_eq!(c.value, p.value);363+    let idx = fg.scan_issue_index().unwrap();364+    assert_eq!(idx.len(), 1);365+    assert!(fg.scan_numbers().unwrap().contains_key(&p.value.id));366+    // no locks anywhere: two writers are last-wins, both leave valid docs367+    fg.save_entity(&rel, &p.value, "first\n").unwrap();368+    fg.save_entity(&rel, &p.value, "second\n").unwrap();369+    let r: Parsed<Issue> = fg.load_entity(&rel).unwrap();370+    assert_eq!(r.body, "second\n");371+}