diff --git a/Cargo.lock b/Cargo.lock index 8c0fb58..887c69c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,7 +276,7 @@ dependencies = [ [[package]] name = "dream-cli" -version = "0.1.1" +version = "0.2.0" dependencies = [ "clap", "dream-core", @@ -288,7 +288,7 @@ dependencies = [ [[package]] name = "dream-core" -version = "0.1.1" +version = "0.2.0" dependencies = [ "chrono", "dream-gate", @@ -304,7 +304,7 @@ dependencies = [ [[package]] name = "dream-gate" -version = "0.1.1" +version = "0.2.0" dependencies = [ "chrono", "failure-fingerprint", diff --git a/Cargo.toml b/Cargo.toml index b6ba8d4..d397a19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,4 @@ rust-version = "1.89" license = "MIT OR Apache-2.0" repository = "https://github.com/DreamLab-AI/dream-engine" authors = ["DreamLab AI"] -version = "0.1.1" +version = "0.2.0" diff --git a/crates/dream-cli/Cargo.toml b/crates/dream-cli/Cargo.toml index 84834bb..6451812 100644 --- a/crates/dream-cli/Cargo.toml +++ b/crates/dream-cli/Cargo.toml @@ -21,8 +21,8 @@ name = "dream_cli" path = "src/lib.rs" [dependencies] -dream-gate = { path = "../dream-gate", version = "0.1.1" } -dream-core = { path = "../dream-core", version = "0.1.1" } +dream-gate = { path = "../dream-gate", version = "0.2.0" } +dream-core = { path = "../dream-core", version = "0.2.0" } clap = { version = "4", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/crates/dream-core/Cargo.toml b/crates/dream-core/Cargo.toml index 8f34c27..e77737a 100644 --- a/crates/dream-core/Cargo.toml +++ b/crates/dream-core/Cargo.toml @@ -14,7 +14,7 @@ categories = ["development-tools", "development-tools::testing"] authors.workspace = true [dependencies] -dream-gate = { path = "../dream-gate", version = "0.1.1" } +dream-gate = { path = "../dream-gate", version = "0.2.0" } failure-fingerprint = { path = "../failure-fingerprint", version = "0.1.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/dream-core/src/source.rs b/crates/dream-core/src/source.rs index 2868f22..aa0125b 100644 --- a/crates/dream-core/src/source.rs +++ b/crates/dream-core/src/source.rs @@ -377,6 +377,34 @@ pub struct SourceRequest { pub lines: Option, } +impl SourceRequest { + /// Parse `path`, `path:10-80` or `path:42` as written in a report's + /// "Sources wanted next night" list. A trailing `:a-b` that parses as a + /// [`LineRange`] becomes the range; anything else stays part of the path. + /// `None` for an empty token. + pub fn parse(token: &str) -> Option { + let t = token.trim().trim_matches('`').trim(); + let t = t.strip_prefix("./").unwrap_or(t); + if t.is_empty() { + return None; + } + if let Some((head, tail)) = t.rsplit_once(':') { + if let Some(range) = LineRange::parse(tail) { + if !head.is_empty() { + return Some(Self { + path: head.to_string(), + lines: Some(range), + }); + } + } + } + Some(Self { + path: t.to_string(), + lines: None, + }) + } +} + /// Parse the source planner's reply. Accepts `{"files":[{"path":.., /// "lines":"a-b"}]}` or `{"files":["path", ..]}`, tolerating fences and prose /// around the JSON object. @@ -442,9 +470,24 @@ pub fn mentioned_paths(text: &str, tracked: &HashSet<&str>, deny: &Denylist) -> out } -/// Merge planner picks (first) with evidence-mentioned paths, validating each -/// and stopping at `max_files`. Returns the kept requests and the rejections. +/// How many of `max_files` slots requested paths may take: half, at least one. +pub fn requested_slots(max_files: usize) -> usize { + (max_files / 2).max(1).min(max_files) +} + +/// Merge requested paths (first, capped at [`requested_slots`]), planner +/// picks, then evidence-mentioned paths, validating each and stopping at +/// `max_files`. Returns the kept requests and the rejections. +/// +/// Requested paths come from the previous night's own "Sources wanted next +/// night" list (or an operator), so they are honoured ahead of the planner — +/// a model asked to pick files cannot be held to a list, and that is how +/// the same file came to be asked for three nights running. The cap keeps a +/// stale request from starving the planner of the files tonight's evidence +/// actually points at. Every requested path still passes the same tree and +/// denylist checks as a planner pick: the report proposes, the tree decides. pub fn select( + requested: &[SourceRequest], planned: &[SourceRequest], mentioned: &[String], tracked: &HashSet<&str>, @@ -453,9 +496,12 @@ pub fn select( ) -> (Vec, Vec<(String, PathRejection)>) { let mut kept: Vec = Vec::new(); let mut rejected = Vec::new(); - let candidates = planned + let reserve = requested_slots(max_files); + let candidates = requested .iter() + .take(reserve) .cloned() + .chain(planned.iter().cloned()) .chain(mentioned.iter().map(|p| SourceRequest { path: p.clone(), lines: None, @@ -527,7 +573,23 @@ pub fn source_plan_prompt( evidence: &str, index: &str, max_files: usize, + supplied: &[SourceRequest], ) -> String { + let supplied_note = if supplied.is_empty() { + String::new() + } else { + let list = supplied + .iter() + .map(|r| match r.lines { + Some(l) => format!("- `{}` lines {}-{}", r.path, l.start, l.end), + None => format!("- `{}`", r.path), + }) + .collect::>() + .join("\n"); + format!( + "\nAlready being read tonight at the previous night's request (do not name these again; spend your picks on what else the change must stay consistent with):\n{list}\n" + ) + }; let cmds = if commands.is_empty() { "(none)".to_string() } else { @@ -550,6 +612,7 @@ Tonight's evidence (tail): Tracked files at the commit under test: {index} +{supplied_note} Name at most {max_files} files whose exact current text you need in order to write a correct unified diff (the files you would change, plus anything a change must stay consistent with — the evaluator scripts, the test that fails, the config it reads). Prefer a line range for large files. Respond with ONLY a JSON object, no prose: @@ -630,6 +693,9 @@ pub struct FileRecord { pub lines: Option, /// `inlined` | `unreadable` | `over-budget`. pub status: String, + /// Whether the previous night (or the operator) asked for this file. + #[serde(default)] + pub requested: bool, } /// Receipt of tonight's tree read, persisted as `source.json`. @@ -645,6 +711,13 @@ pub struct SourceRecord { pub rejected: Vec<(String, String)>, /// Size of the rendered section. pub section_bytes: usize, + /// Requested paths that were inlined tonight (`path` or `path:a-b`). + #[serde(default)] + pub requested_honoured: Vec, + /// Requested paths that were not inlined, with the reason + /// (`not-in-tree`, `denied`, `over-cap`, `over-budget`, `unreadable`…). + #[serde(default)] + pub requested_refused: Vec<(String, String)>, } /// A file fetched from the tree, ready to render. @@ -656,6 +729,16 @@ pub struct FetchedFile { pub lines: Option, /// The file text; `None` when unreadable. pub text: Option, + /// Whether this file was requested rather than planned or mentioned. + pub requested: bool, +} + +/// `path` or `path:a-b` for a request, as the engine writes it in records. +pub fn request_key(path: &str, lines: Option) -> String { + match lines { + Some(l) => format!("{path}:{}-{}", l.start, l.end), + None => path.to_string(), + } } /// Whether `text` carries key material that must never reach a provider. @@ -709,6 +792,12 @@ pub fn render_section( ); let mut records = Vec::new(); let mut spent = 0usize; + // Requested files share at most half the byte budget between them, so a + // stale or over-broad request cannot crowd out what tonight's planner + // chose; a requested file beyond that is clipped (head + tail, marked), + // never silently dropped. + let requested_ceiling = cfg.budget_bytes / 2; + let mut spent_requested = 0usize; for f in files { let Some(text) = &f.text else { records.push(FileRecord { @@ -717,6 +806,7 @@ pub fn render_section( clipped: false, lines: f.lines, status: "unreadable".into(), + requested: f.requested, }); continue; }; @@ -727,16 +817,18 @@ pub fn render_section( clipped: false, lines: f.lines, status: "withheld-key-material".into(), + requested: f.requested, }); continue; } - if spent >= cfg.budget_bytes { + if spent >= cfg.budget_bytes || (f.requested && spent_requested >= requested_ceiling) { records.push(FileRecord { path: f.path.clone(), bytes: 0, clipped: false, lines: f.lines, status: "over-budget".into(), + requested: f.requested, }); continue; } @@ -751,13 +843,24 @@ pub fn render_section( format!("{} lines, {} bytes", text.lines().count(), text.len()), ), }; - let cap = cfg.file_cap_bytes.min(cfg.budget_bytes - spent); + let mut cap = cfg.file_cap_bytes.min(cfg.budget_bytes - spent); + if f.requested { + cap = cap.min(requested_ceiling - spent_requested); + } let (body, clipped) = clip_file(&body, cap); let body = redact(&body); spent += body.len(); + if f.requested { + spent_requested += body.len(); + } + let tag = if f.requested { + " — requested by the previous night" + } else { + "" + }; out.push_str(&format!( - "### `{}` ({})\n```\n{}\n```\n\n", - f.path, header, body + "### `{}` ({}{})\n```\n{}\n```\n\n", + f.path, header, tag, body )); records.push(FileRecord { path: f.path.clone(), @@ -765,6 +868,7 @@ pub fn render_section( clipped, lines: f.lines, status: "inlined".into(), + requested: f.requested, }); } let omitted: Vec<&str> = records @@ -781,8 +885,65 @@ pub fn render_section( (out, records) } -/// Everything [`gather`] needs besides the model. +/// Tracked, readable files under `prefix` (with or without a trailing `/`), +/// shallowest first then by name. Empty when `prefix` is itself a tracked +/// file or matches nothing, so a plain file request is left alone. +pub fn expand_directory(prefix: &str, files: &[TrackedFile], deny: &Denylist) -> Vec { + let p = prefix.trim().trim_matches('`').trim(); + let p = p.strip_prefix("./").unwrap_or(p).trim_end_matches('/'); + if p.is_empty() || p.split('/').any(|c| c == "..") || files.iter().any(|f| f.path == p) { + return Vec::new(); + } + let want = format!("{p}/"); + let mut under: Vec<&TrackedFile> = files + .iter() + .filter(|f| { + f.path.starts_with(&want) + && f.size <= MAX_READABLE_BYTES + && !deny.denies(&f.path) + && !is_binary_ext(&f.path) + }) + .collect(); + under.sort_by(|a, b| { + a.path + .matches('/') + .count() + .cmp(&b.path.matches('/').count()) + .then(a.path.cmp(&b.path)) + }); + under.into_iter().map(|f| f.path.clone()).collect() +} + +/// The closing line of the section when anything was requested: which +/// requests were honoured and which were not, so the model does not ask the +/// operator for a file the engine has already read or already refused. +pub fn requested_note(honoured: &[String], refused: &[(String, String)]) -> String { + let mut out = String::from("\nRequested by the previous night: "); + if honoured.is_empty() { + out.push_str("none honoured"); + } else { + out.push_str(&format!("honoured {}", honoured.join(", "))); + } + if !refused.is_empty() { + let list = refused + .iter() + .map(|(p, why)| format!("{p} ({why})")) + .collect::>() + .join(", "); + out.push_str(&format!("; not inlined {list}")); + } + out.push_str( + ". Do not ask the operator for source files: list what you still need under \ + **Sources wanted next night** and the engine reads it next time.\n", + ); + out +} + +/// Everything [`gather`] needs besides the model. Build it with +/// [`GatherInput::new`] and [`GatherInput::with_requested`]; the struct is +/// `non_exhaustive` so a later field is not a breaking change. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct GatherInput<'a> { /// The checkout to read from. pub repo: &'a Path, @@ -800,6 +961,42 @@ pub struct GatherInput<'a> { pub cfg: &'a SourceConfig, /// Extra denied paths. pub deny: &'a Denylist, + /// Paths the previous night (or the operator) asked to see tonight. + /// Honoured first, capped at [`requested_slots`] and half the byte budget. + pub requested: &'a [SourceRequest], +} + +impl<'a> GatherInput<'a> { + /// A gather input with no requested paths. + #[allow(clippy::too_many_arguments)] + pub fn new( + repo: &'a Path, + commit: &'a str, + deep: &'a str, + scans: &'a str, + commands: &'a [String], + evidence: &'a str, + cfg: &'a SourceConfig, + deny: &'a Denylist, + ) -> Self { + Self { + repo, + commit, + deep, + scans, + commands, + evidence, + cfg, + deny, + requested: &[], + } + } + + /// The same input, honouring `requested` first. + pub fn with_requested(mut self, requested: &'a [SourceRequest]) -> Self { + self.requested = requested; + self + } } /// One completion with an explicit token budget. @@ -855,6 +1052,7 @@ pub async fn gather( evidence, cfg, deny, + requested, } = *input; if !cfg.enabled { return None; @@ -867,6 +1065,49 @@ pub async fn gather( .map(|f| f.path.as_str()) .collect(); + // Requested paths are validated before the planner runs, so the planner + // is told exactly which of them it need not spend picks on. + let reserve = requested_slots(cfg.max_files); + let mut honoured_requests: Vec = Vec::new(); + let mut requested_refused: Vec<(String, String)> = Vec::new(); + // A request for a directory (`crates/x/` or any tracked prefix) stands + // for its files, shallowest first, within the same reservation. + let expanded: Vec = requested + .iter() + .flat_map(|r| { + let under = expand_directory(&r.path, &files, deny); + if under.is_empty() { + vec![r.clone()] + } else { + under + .into_iter() + .map(|path| SourceRequest { path, lines: None }) + .collect() + } + }) + .collect(); + for (i, r) in expanded.iter().enumerate() { + let key = request_key(&r.path, r.lines); + if i >= reserve { + requested_refused.push((key, "over-cap".into())); + continue; + } + match validate_path_with(&r.path, &tracked, deny) { + Ok(path) if oversize.contains(path.as_str()) => { + requested_refused.push((key, "oversize".into())) + } + Ok(path) if is_binary_ext(&path) => requested_refused.push((key, "denied".into())), + Ok(path) => { + if !honoured_requests.iter().any(|h| h.path == path) { + honoured_requests.push(SourceRequest { + path, + lines: r.lines, + }); + } + } + Err(why) => requested_refused.push((key, format!("{why:?}").to_lowercase())), + } + } let evidence_tail = tail_chars(evidence, PLANNER_EVIDENCE_CHARS); let prompt = source_plan_prompt( deep, @@ -875,6 +1116,7 @@ pub async fn gather( &redact(evidence_tail), &index_listing(&files, cfg.index_cap_chars, deny), cfg.max_files, + &honoured_requests, ); let (planned, planner_status) = plan_with(llm, fallback, &prompt).await; if planned.is_empty() { @@ -888,7 +1130,14 @@ pub async fn gather( .into_iter() .filter(|p| !oversize.contains(p.as_str())) .collect(); - let (selected, rejected) = select(&planned, &mentioned, &tracked, cfg.max_files, deny); + let (selected, rejected) = select( + &honoured_requests, + &planned, + &mentioned, + &tracked, + cfg.max_files, + deny, + ); if selected.is_empty() { info!("tree-read selected no files — prompt carries no source section"); return None; @@ -903,15 +1152,31 @@ pub async fn gather( } else { read_blob(repo, commit, &r.path) }, + requested: honoured_requests.iter().any(|h| h.path == r.path), }) .collect(); - let (section, records) = render_section(commit, &fetched, cfg, redact); + let (mut section, records) = render_section(commit, &fetched, cfg, redact); let inlined = records.iter().filter(|r| r.status == "inlined").count(); + // Every requested path ends in exactly one of the two lists. + let mut requested_honoured: Vec = Vec::new(); + for rec in records.iter().filter(|r| r.requested) { + let key = request_key(&rec.path, rec.lines); + if rec.status == "inlined" { + requested_honoured.push(key); + } else { + requested_refused.push((key, rec.status.clone())); + } + } + if !requested.is_empty() { + section.push_str(&requested_note(&requested_honoured, &requested_refused)); + } info!( planner = %planner_status, selected = selected.len(), inlined, rejected = rejected.len(), + requested = requested.len(), + requested_honoured = requested_honoured.len(), bytes = section.len(), "tree-read source section built" ); @@ -927,6 +1192,8 @@ pub async fn gather( .map(|(p, r)| (p, format!("{r:?}"))) .collect(), section_bytes: section.len(), + requested_honoured, + requested_refused, }; Some((section, record)) } @@ -1175,11 +1442,13 @@ mod tests { path: "a.rs".into(), lines: None, text: Some("fn a() {}".into()), + requested: false, }, FetchedFile { path: "fixture.rs".into(), lines: None, text: Some(format!("const K: &str = \"{nsec}\";")), + requested: false, }, ]; let (section, records) = @@ -1365,7 +1634,7 @@ mod tests { }, ]; let mentioned = vec!["b.rs".to_string(), "a.rs".into(), "c.rs".into()]; - let (kept, rejected) = select(&planned, &mentioned, &t, 2, &Denylist::default()); + let (kept, rejected) = select(&[], &planned, &mentioned, &t, 2, &Denylist::default()); assert_eq!( kept.iter().map(|k| k.path.as_str()).collect::>(), vec!["b.rs", "a.rs"] @@ -1445,26 +1714,31 @@ mod tests { path: "a.rs".into(), lines: None, text: Some("fn a() {}\n".into()), + requested: false, }, FetchedFile { path: "gone.rs".into(), lines: None, text: None, + requested: false, }, FetchedFile { path: "b.rs".into(), lines: Some(LineRange { start: 2, end: 2 }), text: Some("x\ny\nz".into()), + requested: false, }, FetchedFile { path: "c.rs".into(), lines: None, text: Some("c".repeat(100)), + requested: false, }, FetchedFile { path: "d.rs".into(), lines: None, text: Some("d".into()), + requested: false, }, ]; let redact = |s: &str| s.replace("fn", "FN"); @@ -1694,16 +1968,8 @@ mod tests { )]); let deny = Denylist::new(["session-key.txt"]); let cmds: Vec = vec![]; - let input = GatherInput { - repo: d.path(), - commit: &rev, - deep: "d", - scans: "s", - commands: &cmds, - evidence: "", - cfg: &SourceConfig::default(), - deny: &deny, - }; + let cfg = SourceConfig::default(); + let input = GatherInput::new(d.path(), &rev, "d", "s", &cmds, "", &cfg, &deny); let (section, rec) = futures::executor::block_on(gather(&fake, None, &input, &|s: &str| s.to_string())) .expect("a section"); @@ -1720,16 +1986,18 @@ mod tests { let primary = Fake::new(vec![Err(LlmError::Empty)]); let fb = Fake::new(vec![Ok(r#"{"files":["src/b.rs"]}"#.into())]); let cmds: Vec = vec![]; - let input = GatherInput { - repo: d.path(), - commit: &rev, - deep: "d", - scans: "s", - commands: &cmds, - evidence: "error at src/a.rs:3", - cfg: &SourceConfig::default(), - deny: &Denylist::default(), - }; + let deny = Denylist::default(); + let cfg = SourceConfig::default(); + let input = GatherInput::new( + d.path(), + &rev, + "d", + "s", + &cmds, + "error at src/a.rs:3", + &cfg, + &deny, + ); let (section, rec) = futures::executor::block_on(gather(&primary, Some(&fb), &input, &|s: &str| { s.to_string() @@ -1750,7 +2018,8 @@ mod tests { ..SourceConfig::default() }; let silent = Fake::new(vec![]); - let input = GatherInput { cfg: &off, ..input }; + let mut input = input; + input.cfg = &off; assert!(futures::executor::block_on( gather(&silent, None, &input, &|s: &str| s.to_string()) ) @@ -1775,4 +2044,181 @@ mod tests { assert_eq!(rec.outcome, RepairOutcome::NoPatch("cannot".into())); assert!(rec.repairs("old") && !rec.repairs("other")); } + + #[test] + fn source_request_parse_splits_a_trailing_line_range() { + assert_eq!( + SourceRequest::parse("`src/a.rs:10-80`"), + Some(SourceRequest { + path: "src/a.rs".into(), + lines: Some(LineRange { start: 10, end: 80 }), + }) + ); + assert_eq!( + SourceRequest::parse("./conf/x:y.toml"), + Some(SourceRequest { + path: "conf/x:y.toml".into(), + lines: None, + }) + ); + assert_eq!(SourceRequest::parse(" "), None); + } + + #[test] + fn select_honours_requested_first_but_reserves_only_half_the_slots() { + let t = tracked(&["r1.rs", "r2.rs", "r3.rs", "p1.rs", "m1.rs"]); + let req = |p: &str| SourceRequest { + path: p.into(), + lines: None, + }; + let requested = vec![req("r1.rs"), req("r2.rs"), req("r3.rs")]; + let planned = vec![req("p1.rs")]; + let mentioned = vec!["m1.rs".to_string()]; + let (kept, _) = select( + &requested, + &planned, + &mentioned, + &t, + 4, + &Denylist::default(), + ); + let paths: Vec<&str> = kept.iter().map(|k| k.path.as_str()).collect(); + // Two requested (half of four), then the planner, then the mention; r3 waits. + assert_eq!(paths, vec!["r1.rs", "r2.rs", "p1.rs", "m1.rs"]); + assert_eq!(requested_slots(1), 1); + assert_eq!(requested_slots(12), 6); + } + + #[test] + fn render_caps_requested_files_at_half_the_byte_budget() { + let cfg = SourceConfig { + budget_bytes: 2_000, + file_cap_bytes: 2_000, + ..SourceConfig::default() + }; + let files = vec![ + FetchedFile { + path: "req.rs".into(), + lines: None, + text: Some("x".repeat(1_800)), + requested: true, + }, + FetchedFile { + path: "plan.rs".into(), + lines: None, + text: Some("y".repeat(400)), + requested: false, + }, + ]; + let (section, records) = render_section("c", &files, &cfg, &|s: &str| s.to_string()); + assert!(records[0].clipped, "requested file is clipped to its half"); + // At most half the budget of the file's own text survives; the rest is + // the elision marker, which counts against the budget like any text. + let body = section.split("```").nth(1).unwrap_or(""); + let kept_x = body.matches("xxxx").count() * 4; + assert!(kept_x <= 1_000, "{kept_x}"); + assert_eq!(records[1].status, "inlined"); + assert!(!records[1].requested && records[0].requested); + assert!(section.contains("requested by the previous night")); + } + + #[test] + fn planner_prompt_lists_already_supplied_requests() { + let supplied = vec![SourceRequest { + path: "src/a.rs".into(), + lines: Some(LineRange { start: 1, end: 9 }), + }]; + let p = source_plan_prompt("d", "s", &[], "", "", 12, &supplied); + assert!(p.contains("Already being read tonight")); + assert!(p.contains("`src/a.rs` lines 1-9")); + let p0 = source_plan_prompt("d", "s", &[], "", "", 12, &[]); + assert!(!p0.contains("Already being read tonight")); + } + + #[test] + fn gather_honours_requested_paths_and_records_refusals() { + let (d, rev) = repo_with(&[("src/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]); + // The planner names nothing: without requests the night has no section. + let fake = Fake::new(vec![Ok(r#"{"files":[]}"#.into())]); + let cmds: Vec = vec![]; + let cfg = SourceConfig::default(); + let deny = Denylist::default(); + let requested = vec![ + SourceRequest { + path: "src/b.rs".into(), + lines: None, + }, + SourceRequest { + path: "nowhere.rs".into(), + lines: None, + }, + SourceRequest { + path: "../etc/passwd".into(), + lines: None, + }, + ]; + let input = GatherInput::new(d.path(), &rev, "d", "s", &cmds, "", &cfg, &deny) + .with_requested(&requested); + let (section, rec) = + futures::executor::block_on(gather(&fake, None, &input, &|s: &str| s.to_string())) + .expect("a section from the request alone"); + assert!(section.contains("fn b()") && !section.contains("fn a()")); + assert!(section.contains("Requested by the previous night: honoured src/b.rs")); + assert!(section.contains("Sources wanted next night")); + assert_eq!(rec.requested_honoured, vec!["src/b.rs".to_string()]); + assert_eq!( + rec.requested_refused.len(), + 2, + "{:?}", + rec.requested_refused + ); + assert!(rec.files[0].requested); + // The planner was told what it need not pick. + assert!(fake.seen.lock().unwrap()[0].0.contains("`src/b.rs`")); + } + + #[test] + fn a_directory_request_expands_to_its_files_shallowest_first() { + let files = vec![ + TrackedFile { + path: "crates/x/src/deep/z.rs".into(), + size: 10, + }, + TrackedFile { + path: "crates/x/Cargo.toml".into(), + size: 10, + }, + TrackedFile { + path: "crates/x/src/lib.rs".into(), + size: 10, + }, + TrackedFile { + path: "crates/x/logo.png".into(), + size: 10, + }, + TrackedFile { + path: "crates/xy/src/lib.rs".into(), + size: 10, + }, + ]; + let d = Denylist::default(); + assert_eq!( + expand_directory("crates/x/", &files, &d), + vec![ + "crates/x/Cargo.toml".to_string(), + "crates/x/src/lib.rs".into(), + "crates/x/src/deep/z.rs".into() + ] + ); + assert_eq!( + expand_directory("crates/x", &files, &d).len(), + 3, + "no trailing slash" + ); + assert!( + expand_directory("crates/x/Cargo.toml", &files, &d).is_empty(), + "a file is not expanded" + ); + assert!(expand_directory("nowhere/", &files, &d).is_empty()); + } } diff --git a/docs/adr/ADR-DE-008-requested-sources-are-honoured-first.md b/docs/adr/ADR-DE-008-requested-sources-are-honoured-first.md new file mode 100644 index 0000000..d83d689 --- /dev/null +++ b/docs/adr/ADR-DE-008-requested-sources-are-honoured-first.md @@ -0,0 +1,30 @@ +--- +id: ADR-DE-008 +title: Sources the previous night asked for are read first, capped, and never asked of the operator +date: 2026-10-17 +decision_status: accepted +implementation_status: implemented +activation_status: staged +supersedes: [] +owner: jjohare +review_trigger: a change to the source planner's selection order, to `requested_slots`, or to the half-budget reservation +--- + +# ADR-DE-008 — Sources the previous night asked for are read first, capped, and never asked of the operator + +## Context +The tree-read stage (ADR-2114 in the consuming engine) lets an LLM planner pick up to `max_files` files to inline. The planner saw only tonight's evidence and the file index. When a night ended INCONCLUSIVE for want of a file, its report named the file, and the consuming engine turned that line into an operator question. Across four repositories the same request then reached the human three nights running ("operator-approved 10-09, now missed twice"): a model asked to choose files cannot be held to a list, and the human was relaying a list the engine itself had written. + +## Decision +- `GatherInput` carries `requested: &[SourceRequest]`: paths, optionally with a line range, that the previous night or the operator asked to see. The struct is `#[non_exhaustive]` with `GatherInput::new` and `with_requested`, so the next field is not a breaking change (this one was: 0.1.1 → 0.2.0). +- `select` honours requested paths **first**, then planner picks, then evidence-mentioned paths. Requested paths take at most `requested_slots(max_files)` = half the file slots (at least one), and `render_section` lets them share at most half the byte budget; beyond that a requested file is clipped with the usual marker, never dropped silently. +- A request for a directory (a tracked prefix, with or without a trailing `/`) stands for its readable files, shallowest first, inside the same reservation; a consumer can therefore pass a slot's path-shaped `scan` entries as requests. +- Every requested path passes the same checks as a planner pick: relative, no traversal, tracked at the dispatched commit, not denied, not binary, not oversize, no key material. The report proposes; the tree decides. +- The planner prompt lists the honoured requests as already being read, so it does not spend picks on them. +- The section ends with one line naming which requests were honoured and which were not, with the reason, and tells the model to list further needs under **Sources wanted next night** rather than ask the operator. `SourceRecord` carries the same two lists (`requested_honoured`, `requested_refused`) so the consumer can expire a request once it has been met. +- dream-core stays unaware of report headings and of operators: parsing the previous report, storing requests, expiring them (met once, or after three nights) and alerting on a path requested again after it was inlined are the consumer's job. + +## Consequences +- A stale or over-broad request can occupy at most half of tonight's read; the planner always keeps half for what tonight's evidence points at. +- Source supply stops being a governance decision. A human sees a source request only if the consumer's expiry raises an alert about a path that was inlined and asked for again, which indicates the wrong window or a real blocker. +- Consumers building `GatherInput` by struct literal must move to the constructor. diff --git a/docs/adr/README.md b/docs/adr/README.md index 121a1e0..977f174 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,3 +11,4 @@ One page per decision, with frontmatter in the estate format. New records use th | [ADR-DE-005](ADR-DE-005-seams.md) | Seams: the five traits the engine is generic over | accepted | implemented | | [ADR-DE-006](ADR-DE-006-automerge-is-an-option.md) | Automerge is an option, default off | accepted | implemented | | [ADR-DE-007](ADR-DE-007-a-night-is-identified-by-its-directory.md) | A night is identified by its directory, not by the run id | accepted | implemented | +| [ADR-DE-008](ADR-DE-008-requested-sources-are-honoured-first.md) | Sources the previous night asked for are read first, capped, and never asked of the operator | accepted | implemented | diff --git a/docs/diagrams/review-ledger.jsonl b/docs/diagrams/review-ledger.jsonl new file mode 100644 index 0000000..9a265a0 --- /dev/null +++ b/docs/diagrams/review-ledger.jsonl @@ -0,0 +1,9 @@ +{"ts":"2026-10-17T02:47:00.173Z","kind":"review","reviewer":"glm","shard":"crates","commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":null,"pack_sha256":"3b69e5f37e874ea7bcddb98aa7c72f692d1b03f6b094b76857fe792fad788aa1","tokens":{"input":6318,"output":31682},"est_usd":0,"findings":"docs/review/2026-10-17-glm.md","high_severity":14,"changed_topics":null,"skipped":null} +{"ts":"2026-10-17T02:47:00.173Z","kind":"review","reviewer":"glm","shard":"gate","commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":null,"pack_sha256":"43e14cc9119905358d0991705cf9c88d69988ec27b646b958e80165d9fc9213b","tokens":{"input":9912,"output":46574},"est_usd":0,"findings":"docs/review/2026-10-17-glm.md","high_severity":11,"changed_topics":null,"skipped":null} +{"ts":"2026-10-17T02:47:00.173Z","kind":"review","reviewer":"glm","shard":"nightly","commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":null,"pack_sha256":"796d6cd9c3ac772920856c224c007e9365d6b7b6374b657e97f425b204d2c0e7","tokens":{"input":12878,"output":54223},"est_usd":0,"findings":"docs/review/2026-10-17-glm.md","high_severity":12,"changed_topics":null,"skipped":null} +{"ts":"2026-10-17T02:47:00.173Z","kind":"review","reviewer":"glm","shard":"selection","commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":null,"pack_sha256":"70292ccc28e410a3d3a828f2eecf5f5b6d51a145b3f35f9809e0a1128995c856","tokens":{"input":8300,"output":41062},"est_usd":0,"findings":"docs/review/2026-10-17-glm.md","high_severity":12,"changed_topics":null,"skipped":null} +{"ts":"2026-10-17T03:32:09.926Z","kind":"audit","reviewer":"gemini","shard":"nightly","commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":null,"pack_sha256":"796d6cd9c3ac772920856c224c007e9365d6b7b6374b657e97f425b204d2c0e7","tokens":{"input":7046},"est_usd":0,"findings":null,"high_severity":0,"changed_topics":3,"skipped":"budget: $9.98 spent this month + $0.24 estimated exceeds $10"} +{"ts":"2026-10-17T03:32:09.926Z","kind":"audit","reviewer":"gemini","shard":"crates","commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":null,"pack_sha256":"3b69e5f37e874ea7bcddb98aa7c72f692d1b03f6b094b76857fe792fad788aa1","tokens":{"input":3471},"est_usd":0,"findings":null,"high_severity":0,"changed_topics":2,"skipped":"budget: $9.98 spent this month + $0.23 estimated exceeds $10"} +{"ts":"2026-10-17T03:32:09.926Z","kind":"audit","reviewer":"gemini","shard":"selection","commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":null,"pack_sha256":"70292ccc28e410a3d3a828f2eecf5f5b6d51a145b3f35f9809e0a1128995c856","tokens":{"input":4562},"est_usd":0,"findings":null,"high_severity":0,"changed_topics":2,"skipped":"budget: $9.98 spent this month + $0.23 estimated exceeds $10"} +{"ts":"2026-10-17T03:32:09.926Z","kind":"audit","reviewer":"gemini","shard":"gate","commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":null,"pack_sha256":"43e14cc9119905358d0991705cf9c88d69988ec27b646b958e80165d9fc9213b","tokens":{"input":5479},"est_usd":0,"findings":null,"high_severity":0,"changed_topics":1,"skipped":"budget: $9.98 spent this month + $0.23 estimated exceeds $10"} +{"ts":"2026-10-17T05:17:00.082Z","kind":"triage","reviewer":"glm","shard":null,"commit":"9503e81f149c75cd4b3909d0708824767e94ebb7","commits":{"/home/devuser/workspace/dream-engine":"9503e81f149c75cd4b3909d0708824767e94ebb7"},"pack_sha256":null,"tokens":null,"est_usd":0,"findings":null,"high_severity":0,"changed_topics":null,"skipped":"baseline: first triage run records every repository commit and spends nothing","unresolved":{"count":0,"reasons":{},"examples":[]}} diff --git a/docs/review/2026-10-17-glm.json b/docs/review/2026-10-17-glm.json new file mode 100644 index 0000000..7d33adc --- /dev/null +++ b/docs/review/2026-10-17-glm.json @@ -0,0 +1,1794 @@ +[ + { + "id": "crates:critical:F-01", + "kind": "finding", + "title": "Model-generated code is built and executed before any human sees it, and no isolation mechanism appears anywhere", + "topics": "CRT-02.2, CRT-02.1", + "evidence": "The documented night has the host \"build the candidate and re-run (candidate.rs:296 and 300)\" evaluators against the LLM's \"report with a patch block\", and the shipped `LocalRunner` executes evaluators locally (`crates/dream-core/src/runner.rs:48`). No sandbox, container, or privilege boundary is mentioned for this step in either file.", + "failure": "a prompt-injected or misbehaving model emits a patch whose build script or test code exfiltrates credentials or modifies the host; it runs with the night process's privileges, hours before review.", + "confidence": "medium, inferred — execution of model output is stated; the absence of isolation is an absence in the material, not an asserted fact.", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:critical:F-02", + "kind": "finding", + "title": "The engine enables automerge on LLM-authored PRs; no human-approval step appears anywhere", + "topics": "CRT-02.1, CRT-02.2", + "evidence": "`Forge::enable_automerge` is a first-class seam operation, used at \"publish_draft and optional automerge\" (`crates/dream-core/src/persist.rs:298`). No approval or review gate appears in the trait list, the sequence, or either business paragraph. The live-path testing gap is separately acknowledged: \"push, PR creation and branch-protection paths have not been exercised against a live GitHub\" (`crates/dream-core/src/seams.rs:647`).", + "failure": "overnight, a plausible-but-wrong patch passes its evaluators and merges into the base branch with no human in the loop; the branch-protection call meant to gate it is exactly the path never tested.", + "confidence": "medium, inferred — `enable_automerge` is stated; \"no approval required\" is implied by its place in an autonomous night, not asserted.", + "severity": "high", + "marked_by_authors": "partially — Debt covers the untested forge path; the missing approval gate is unmarked.", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:critical:F-03", + "kind": "finding", + "title": "Publishing runs on ambient `gh`/`git` credentials whose scope is nowhere constrained", + "topics": "CRT-02.1", + "evidence": "`GhForge` works via \"git push and gh CLI\" (`crates/dream-core/src/seams.rs:424`), i.e. whatever credential the environment supplies; no token scoping or least-privilege requirement is stated for the identity that pushes drafts and mutates branch protection.", + "failure": "the night process holds a broad GitHub token (typically an operator's personal credential); any code executing during the night (F-01) or any host misconfiguration acts with full push and branch-protection rights on the repository.", + "confidence": "medium, inferred — the credential mechanism is stated; the absence of scoping is absence in the material.", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:critical:F-04", + "kind": "finding", + "title": "Automerge is ordered before the best-effort memory write: a merged change can leave no durable record", + "topics": "CRT-02.2", + "evidence": "the stated sequence is publish_draft and \"optional automerge\" (`crates/dream-core/src/persist.rs:298`) and only then \"record the night, best effort\" (`crates/dream-core/src/seams.rs:147`: callers \"log and continue\"). Nothing between the two steps is durable.", + "failure": "crash, restart, or disk-full in the window after automerge succeeds: a change is merged into the base branch with no memory record — the audit trail of what the autonomous system did to production is gone.", + "confidence": "high — the ordering and best-effort semantics are both stated; the window follows directly.", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:critical:F-05", + "kind": "finding", + "title": "Engine-side records sit outside the product's encryption envelope", + "topics": "CRT-01.2, CRT-02.1", + "evidence": "the \"private encryption envelope\" exists only on the product side and is \"never published\" (`docs/adr/ADR-DE-002-licence-and-provenance.md:30`), while the engine's durable default is a local JSONL file (`JsonlSink`, `crates/dream-core/src/seams.rs:202`).", + "failure": "default-configured or trial nights persist prompts, patches and evaluator output as plaintext on the runner's disk; the confidentiality the product claims covers only records that never pass through the engine's sinks.", + "confidence": "medium, inferred — the split is stated; the sensitivity of engine-side record contents is not.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-06", + "kind": "finding", + "title": "Forge failures are never raised; the night records a verdict regardless of what happened to the PR", + "topics": "CRT-02.2, CRT-02.1", + "evidence": "\"A forge failure is reported inside the `PrOutcome`, never raised, so the night still records its verdict\" (`crates/dream-core/src/seams.rs:365`); `GhForge` does push and PR creation as separate ambient operations (`seams.rs:424`), which admits partial publishes.", + "failure": "branch pushed but PR creation fails (expired token, rate limit): the night concludes and records its verdict; the remote carries an orphan branch diverging from the recorded outcome; nothing retries or alerts, because notification is infallible (F-08).", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "no — the Debt covers GhForge test coverage, not this failure-containment policy.", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-07", + "kind": "finding", + "title": "Memory is best-effort by design: IO failure silently deletes the audit trail", + "topics": "CRT-02.2, CRT-02.1", + "evidence": "\"Memory writes are best effort and callers log and continue\" (`crates/dream-core/src/seams.rs:147`); the shipped sink is a local JSONL file (`seams.rs:202`).", + "failure": "disk full or sink path unwritable for an entire night: the night keeps publishing and merging; in the morning only stderr logs (if retained) hold any record, and the loss itself is signalled nowhere.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-08", + "kind": "finding", + "title": "`Notifier::notify` is infallible by signature: notification loss is structurally unreportable", + "topics": "CRT-02.1", + "evidence": "stated as an \"Invariant\": \"notify is infallible by signature, so a broken channel cannot change a verdict\" (`crates/dream-core/src/seams.rs:320`). A signature with no error return cannot express delivery failure.", + "failure": "the chat channel is down all night; every notify silently no-ops; nobody learns the autonomous system ran, published, or merged until the damage is found by other means.", + "confidence": "high — the signature fact is stated; silent drop is its direct implication.", + "severity": "medium", + "marked_by_authors": "no — it is presented as an invariant, the opposite of a flag.", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-09", + "kind": "finding", + "title": "No seam call carries a timeout or cancellation; one hung call stalls the night, and the lock likely stalls the ones after it", + "topics": "CRT-02.1, CRT-02.2", + "evidence": "the documented trait surface — `Llm::complete`, `EvaluatorRunner::run`, `Forge::publish_draft`, etc. — has no deadline or cancellation parameter (`crates/dream-core/src/seams.rs`, `crates/dream-core/src/runner.rs` method lists), and \"the async ones return a plain boxed future so the crate links no async runtime\" (`seams.rs:39`): any timeout must come from the host. Nights are serialized by `NightLock` (`Cargo.toml:7`).", + "failure": "the model endpoint accepts the connection and never answers at 01:00; the night hangs holding the night lock; subsequent scheduled nights queue or fail; morning arrives with zero work done and no signal raised.", + "confidence": "medium, inferred — the signatures shown lack timeouts; host-side deadline behaviour is not stated.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-10", + "kind": "finding", + "title": "`ScriptedRunner` ships beside the real runner with no stated production guardrail", + "topics": "CRT-02 (business), CRT-02.1", + "evidence": "both `LocalRunner` (`crates/dream-core/src/runner.rs:48`) and `ScriptedRunner` (`runner.rs:137`) implement `EvaluatorRunner`; the business text endorses \"a full night on a laptop with scripted test results\"; no mechanism is described that keeps scripted results away from the gate in a production deployment. The gate is, by design, \"a pure function\" over whatever results it is handed (`crates/dream-gate/src/gate.rs:8`).", + "failure": "a misconfigured or shortcut-taking host uses `ScriptedRunner` in production; the gate judges fabricated greens and F-02's automerge merges the patch.", + "confidence": "medium, inferred — the coexistence and pure gate are stated; the guardrail's absence is absence in the material.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-11", + "kind": "finding", + "title": "The documented host order is enforced by nothing, and the dangerous mis-orderings are exactly the unguarded ones", + "topics": "CRT-02.2", + "evidence": "\"No driver in this repository enforces it, so the order is not machine-checked here\" — the order exists only in crate docs (`crates/dream-core/src/lib.rs:3`).", + "failure": "an adapter calls `publish_draft`/`enable_automerge` before re-running the evaluators on the candidate; the seams will happily execute in that order, and an unevaluated patch can reach merge.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-12", + "kind": "finding", + "title": "\"Defaults that need nothing external / a full night on a laptop\" contradicts \"Llm: no implementation ships\"", + "topics": "CRT-02 (business), CRT-02.1", + "evidence": "business text: \"The engine ships plain defaults that need nothing external, so a team can try a full night on a laptop\"; CRT-02.1: \"no implementation ships\" for `Llm` (`crates/dream-core/src/seams.rs:96`), while the documented night requires an `Llm` call that produces the patch. The repo also ships `dream-cli`, yet CRT-02 says no driver in this repository drives the sequence.", + "failure": "a team plans pre-production validation around the promised no-services dry run; it cannot exist as described, so real model and forge integration is deferred and first exercised directly in production.", + "confidence": "high — both statements are in the material.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-13", + "kind": "finding", + "title": "ADR-DE-002 still says four core crates; the stamped \"Resolved\" note fixed the README, not the contradiction", + "topics": "CRT-01.1, CRT-01.2", + "evidence": "\"ADR-DE-002 speaks of four core crates (`docs/adr/ADR-DE-002-licence-and-provenance.md:23`), while the workspace has five members (`Cargo.toml:3`)\"; the note claims resolution because the README now describes the built gate (`README.md:57`) — the ADR text itself is untouched and still wrong.", + "failure": "the governing licence/provenance record miscounts the published surface; anyone deriving obligations (which crates are published under MIT OR Apache-2.0) from the ADR gets the wrong set.", + "confidence": "high", + "severity": "low", + "marked_by_authors": "yes (Open) — but the \"Resolved since the earlier stamp\" claim, which is itself false, is unmarked.", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-14", + "kind": "finding", + "title": "The declared MSRV 1.89 is load-bearing (`NightLock`) and never compiled by any CI job", + "topics": "CRT-01.2", + "evidence": "\"the workspace declares a minimum Rust version of 1.89 (`Cargo.toml:7`) because `NightLock` uses `File::try_lock`, but every CI job installs `stable` (`.github/workflows/ci.yml:26`); nothing builds on 1.89\".", + "failure": "a downstream consumer builds on the declared floor and fails to compile on an API added after 1.89 — a publishing incident invisible to this repository's CI.", + "confidence": "high", + "severity": "low", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:critical:F-15", + "kind": "finding", + "title": "\"The fence is physical\" overstates the licence boundary: the reverse direction is convention plus a ban list that cannot see the product tree", + "topics": "CRT-01.2", + "evidence": "business text: \"The fence is physical: that code is not in this repository, so it cannot be pulled in by accident\"; the authors' own Open: \"nothing in this repository verifies that the product's adapter follows the rule in the other direction\"; `deny.toml`'s bans (`deny.toml:26`) inspect only this repository's dependency graph.", + "failure": "over time the product's copyleft adapter absorbs engine behaviour or vendored engine code on its side; no check in this repo can detect it, and the compliance posture asserted in the business text is permanently unverified.", + "confidence": "high", + "severity": "low", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:premortem:R-1", + "kind": "root-cause", + "title": "The night recorded a verdict for a change that was never published (or merged without protection)", + "topics": "CRT-02.1, CRT-02.2", + "evidence": "\"A forge failure is reported inside the `PrOutcome`, never raised, so the night still records its verdict\" (`crates/dream-core/src/seams.rs:365`). \"`GhForge` has one test in this tree and it only proves the trait is dyn-safe… The push, PR creation and branch-protection paths have not been exercised against a live GitHub\" (`crates/dream-core/src/seams.rs:647`, author-flagged Debt). Best-effort memory (`crates/dream-core/src/seams.rs:147`); infallible notify (`crates/dream-core/src/seams.rs:320`).", + "failure": null, + "confidence": "medium, inferred", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:R-2", + "kind": "root-cause", + "title": "The audit trail of what the engine did to the client's code does not survive", + "topics": "CRT-02.1, CRT-02.2", + "evidence": "\"Memory writes are best effort and callers log and continue\" (`crates/dream-core/src/seams.rs:147`); `NullSink` and `JsonlSink` are the shipped sinks (`crates/dream-core/src/seams.rs:157`, `crates/dream-core/src/seams.rs:202`); \"`Notifier::notify` is infallible by signature, so a broken channel cannot change a verdict\" (`crates/dream-core/src/seams.rs:320`); the gate \"can stay a pure function that a replay can re-run\" (`crates/dream-gate/src/gate.rs:8`) — a promise with no matching durability promise for its inputs.", + "failure": null, + "confidence": "medium, inferred", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:R-3", + "kind": "root-cause", + "title": "A model-generated patch was automerged into the client's repository without real evaluation", + "topics": "CRT-02.1, CRT-02.2", + "evidence": "\"no implementation ships\" for `Llm` (`crates/dream-core/src/seams.rs:96`); `ScriptedRunner` at `crates/dream-core/src/runner.rs:137` and the business text \"a full night on a laptop with scripted test results\" (CRT-02); \"No driver in this repository enforces [the order], so the order is not machine-checked here\" (`crates/dream-core/src/lib.rs:3`, author-flagged Open); untested branch-protection path (`crates/dream-core/src/seams.rs:647`); the sequence applies a \"report with a patch block\" then re-runs evaluators (`crates/dream-core/src/candidate.rs:296`, `:300`).", + "failure": null, + "confidence": "low, inferred", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:F-01", + "kind": "finding", + "title": "The declared minimum Rust version has never been built by anyone", + "topics": "CRT-01.2", + "evidence": "The workspace declares `rust-version` 1.89 \"because `NightLock` uses `File::try_lock`\" (`Cargo.toml:7`), but \"every CI job installs `stable`\" (`.github/workflows/ci.yml:26`); author-flagged Debt states \"nothing builds on 1.89, so the declared floor is untested.\"", + "failure": "A deployment pinned to the declared floor fails to compile `NightLock`'s lock path in production, or the floor is simply wrong and a 1.89 toolchain in the field diverges from what CI ever validated.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:premortem:F-02", + "kind": "finding", + "title": "The licence boundary is unenforced on the adapter side", + "topics": "CRT-01.2", + "evidence": "\"nothing in this repository verifies that the product's adapter follows the rule in the other direction; the boundary is a convention plus a dependency ban in the permissive tree\" (author-flagged Open, CRT-01.2); the one-direction rule is only documented (`docs/adr/ADR-DE-002-licence-and-provenance.md:34`).", + "failure": "The adapter pulls a copyleft or host-tying crate into the deployed product; the client's legal review finds it; the operator must rip it out under deadline.", + "confidence": "high for the gap, inferred for the consequence", + "severity": "medium", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:premortem:F-03", + "kind": "finding", + "title": "The ADR's crate count contradicts the workspace", + "topics": "CRT-01.1", + "evidence": "ADR-DE-002 \"speaks of four core crates\" (`docs/adr/ADR-DE-002-licence-and-provenance.md:23`) while the workspace has five members (`Cargo.toml:3`) — author-flagged Open.", + "failure": "A reviewer or auditor working from the ADR misses `dream-cli` entirely, including whatever it wires together.", + "confidence": "high", + "severity": "low", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:premortem:F-04", + "kind": "finding", + "title": "The only forge implementation's real behaviour is untested", + "topics": "CRT-02.1", + "evidence": "`GhForge` at `crates/dream-core/src/seams.rs:424`; its single test \"only proves the trait is dyn-safe\" (`crates/dream-core/src/seams.rs:647`, Debt).", + "failure": "Push, PR creation and branch protection misbehave on first contact with a live GitHub — wrong base branch from `pr_base_branch`, automerge silently not enabled, checks not required.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:F-05", + "kind": "finding", + "title": "The notifier cannot signal that notification is broken", + "topics": "CRT-02.1", + "evidence": "\"`Notifier::notify` is infallible by signature, so a broken channel cannot change a verdict\" (`crates/dream-core/src/seams.rs:320`), presented as an invariant, not flagged.", + "failure": "During an incident the channel is dead; the type system made that unreportable; the on-call human is never alerted and the failure runs all night.", + "confidence": "high for the mechanism, inferred for the scenario", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:premortem:F-06", + "kind": "finding", + "title": "An unwired host can run nights with zero memory", + "topics": "CRT-02.1", + "evidence": "`NullSink` ships as a sink implementation (`crates/dream-core/src/seams.rs:157`) and the engine \"ships plain defaults that need nothing external\" (CRT-02 business text); nothing in the material states a check that a real sink was configured.", + "failure": "An adapter constructs the engine without overriding the sink; entire nights of decisions leave no record at all.", + "confidence": "inferred", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:F-07", + "kind": "finding", + "title": "The model's patch block is trusted input with no stated validation seam", + "topics": "CRT-02.1, CRT-02.2", + "evidence": "`Llm` returns \"a report with a patch block\" which is then built into a candidate (`crates/dream-core/src/candidate.rs:296`); no trait or step in `seams.rs` (`crates/dream-core/src/seams.rs:18`-region, per ADR-DE-005:18's five-trait list) validates patch content or provenance.", + "failure": "A prompt-injected or hallucinated patch is applied verbatim to the client's tree; with automerge enabled it ships.", + "confidence": "low, inferred", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:F-08", + "kind": "finding", + "title": "Scripted results are indistinguishable from real ones at the seam", + "topics": "CRT-02.1", + "evidence": "`ScriptedRunner` at `crates/dream-core/src/runner.rs:137`; the business text advertises \"a full night on a laptop with scripted test results\"; nothing stated marks a run as scripted in any durable output.", + "failure": "A staging wiring is promoted to production unchanged; every candidate passes; nothing in the recorded night reveals the evaluator was canned.", + "confidence": "inferred", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:F-09", + "kind": "finding", + "title": "The intended seam order is documentation, not code", + "topics": "CRT-02.2", + "evidence": "\"the sequence above is the intended host order from the crate docs (`crates/dream-core/src/lib.rs:3`). No driver in this repository enforces it, so the order is not machine-checked here\" — author-flagged Open.", + "failure": "A third-party adapter publishes the draft before re-running evaluators, or records before publishing; the engine's own code cannot prevent it.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:F-10", + "kind": "finding", + "title": "Automerge is a first-class seam method with no safety net behind it", + "topics": "CRT-02.1", + "evidence": "`Forge` exposes `enable_automerge` and `required_status_checks` as trait methods (CRT-02.1 diagram, `crates/dream-core/src/seams.rs`), and the only implementation's branch-protection path is untested (`crates/dream-core/src/seams.rs:647`).", + "failure": "Automerge succeeds while the required checks were never actually configured; merges proceed on green-from-nothing.", + "confidence": "inferred", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "crates:premortem:F-11", + "kind": "finding", + "title": "The deny check is a licence list, not a provenance or advisory check", + "topics": "CRT-01.2", + "evidence": "\"The licence list in `deny.toml` has no deny list; anything not allowed is denied\" (`deny.toml:7`); the ban list targets host-tying crates (`deny.toml:26`); CI runs the `deny` job (`.github/workflows/ci.yml:71`, `docs/adr/ADR-DE-002-licence-and-provenance.md:42`). No advisory or sources check is mentioned.", + "failure": "A compromised but permissively-licensed dependency enters the graph and passes every gate described here.", + "confidence": "inferred", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:premortem:F-12", + "kind": "finding", + "title": "Concurrency is implied but untested: NightLock with no CI coverage of its toolchain", + "topics": "CRT-01.2, CRT-02.1", + "evidence": "`NightLock` exists and drives the 1.89 floor via `File::try_lock` (`Cargo.toml:7`); nothing builds on 1.89 (`.github/workflows/ci.yml:26`, Debt); the async seams return bare boxed futures with no runtime in the crate (`crates/dream-core/src/seams.rs:39`), leaving polling and cancellation wholly to the host.", + "failure": "Two overlapping nights contend for the same workspace or memory file; the lock code — never built at its declared floor and untested here — misbehaves or is bypassed by a host that never polls the lock path.", + "confidence": "low, inferred", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": false + }, + { + "id": "crates:premortem:F-13", + "kind": "finding", + "title": "The intended order publishes before it records, leaving a side-effect-without-audit window", + "topics": "CRT-02.2", + "evidence": "The asserted intended order is: evaluators → model → build/re-run → `publish_draft` and automerge (`crates/dream-core/src/persist.rs:298`) → \"record the night, best effort\" → notify (CRT-02.2 sequence, labelled \"the order a host drives the seams in\").", + "failure": "The process dies between publish and record — or the best-effort record then fails (`crates/dream-core/src/seams.rs:147`) — leaving an automerging PR live in the client's repository with no recorded verdict anywhere.", + "confidence": "inferred", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "crates", + "high": true + }, + { + "id": "gate:critical:F-01", + "kind": "finding", + "title": "Candidate-authored code executes with the runner's privileges, and the guard's path enumeration cannot see files touched indirectly", + "topics": "GAT-01.3, GAT-01.5", + "evidence": "GAT-01.3 Debt states the optional build command and the evaluators \"execute the candidate's own scripts on the evaluation host through the runner\" and \"a candidate-added build script still runs with the runner's privileges; isolation is the host's job\" (`crates/dream-core/src/candidate.rs:296`). GAT-01.5 Debt states the reader \"cannot see files an evaluator reads indirectly (a `Makefile` behind `make test`, a `conftest.py` behind `pytest`); those must be named in the config's `protected` globs\" (`crates/dream-gate/src/guard.rs:703`). The protected set is built only from evaluator-command words (`guard.rs:586`, `guard.rs:661`), config globs, and host-resolved extras (`crates/dream-core/src/replay.rs:143`).", + "failure": "a patch adds or modifies `conftest.py` (not in any protected glob, not named by the evaluator command words). The guard passes, the evaluator imports it during collection, and the candidate's code runs with the runner's full privileges — credential theft, receipt tampering, or pivoting to the host — on the first night that evaluator is pytest-like.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "yes (two Debt markers cover the isolation gap and the indirect-file blind spot separately; the composed escape is not spelled out)", + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:critical:F-02", + "kind": "finding", + "title": "All three guard checks inspect patch text only; candidate code that runs before or between evaluators can rewrite the evaluator scripts themselves, and receipts pin the command line, not the file contents", + "topics": "GAT-01.3, GAT-01.4, GAT-01.5", + "evidence": "The three checks are all on patch text: before git (`crates/dream-core/src/candidate.rs:245`), on normalised text (`crates/dream-core/src/persist.rs:139`), and \"on the applied text\" in the gate (`crates/dream-gate/src/gate.rs:420`). The build command and evaluators run candidate scripts *after* those checks (`crates/dream-core/src/candidate.rs:296`, `candidate.rs:300`). Receipt validation compares \"digest of command text\" to the frozen digest (`crates/dream-gate/src/gate.rs:218`) — the command string, not the contents of the script it invokes.", + "failure": "a candidate-added build script (running with runner privileges per the Debt) rewrites the protected `run_tests.sh` to `echo ok; exit 0` before evaluators run. The evaluator is not silent, the command-line digest still matches the manifest, every layer of the guard sees only the untouched patch text, and a broken or malicious change is recorded as ACCEPT. This defeats the gate even if the host sandboxes execution perfectly.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:critical:F-03", + "kind": "finding", + "title": "Predictable worktree path in the shared temp directory with remove-before-use is a local attack surface", + "topics": "GAT-01.3", + "evidence": "\"the candidate worktree path is predictable, `dream-wt-<8 hex of the repository path digest>-` under the shared temp directory, and any existing directory at that path is removed before use (`crates/dream-core/src/persist.rs:142`). On a multi-user host another local user can pre-create or race that path; the patch file that used to sit beside it is gone, but the worktree directory is not yet unpredictable.\"", + "failure": "a local user watches for the path (fully derivable from the repo path and branch), races the removal, and re-creates the directory or symlinks it; the night's worktree, tree hash inputs, and evaluation happen in attacker-controlled storage — silently corrupted verdicts at minimum.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:critical:F-04", + "kind": "finding", + "title": "The deterministic worktree path also collides with the system's own concurrent or retried runs, cross-contaminating evaluated trees", + "topics": "GAT-01.3", + "evidence": "The path is a pure function of repository path digest and branch (`persist.rs:142`) and any existing directory is removed before use. The material describes no locking or serialisation of candidate runs, and 8 hex digits (32 bits) also permits cross-repository collisions.", + "failure": "a stuck night A is evaluating in `dream-wt-…-main`; an operator or scheduler starts retry B for the same repo and branch; B removes A's live worktree and rebuilds it with patch B. A's still-running evaluators now test B's tree, and A's receipts — recorded under A's attempt — describe B's behaviour. A patch with failing tests can be ACCEPTed on the strength of another run's clean tree.", + "confidence": "medium, inferred (path determinism and removal are stated; run concurrency control is not shown)", + "severity": "high", + "marked_by_authors": "no (the Debt covers the external attacker, not self-collision)", + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:critical:F-05", + "kind": "finding", + "title": "Harness-over-evidence precedence plus Missing-on-suppression lets one removed or broken receipt mask all recorded evidence of failure", + "topics": "GAT-01.2, GAT-01.4, GAT-01 (For the business)", + "evidence": "A missing, duplicate, or digest-mismatched required receipt is replaced by a synthesised Missing receipt (`crates/dream-gate/src/gate.rs:228`), which is a harness veto (`gate.rs:124`). Precedence: \"any harness veto → BLOCKED-ENV (`gate.rs:502`)\" outranks \"else any evidence veto → REJECT (`gate.rs:504`)\". The business section states BLOCKED-ENV \"does not count against the repository\" and explicitly contemplates tampered results, routing them to \"broken test machine\".", + "failure": "receipts for evaluator A show Failed (evidence → REJECT); the receipt for evaluator B is deleted or its digest field broken (write access to the receipt store is nowhere excluded). B becomes Missing → harness veto → the whole night is BLOCKED-ENV and the recorded failure against the candidate vanishes.", + "confidence": "high (mechanism fully stated; write-access precondition for the adversary is not shown)", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:critical:F-06", + "kind": "finding", + "title": "A candidate can convert failing tests into BLOCKED-ENV by inducing a timeout or silence", + "topics": "GAT-01.1, GAT-01.2", + "evidence": "TimedOut and Silent map to harness vetoes (`crates/dream-gate/src/gate.rs:124-138`), and any harness veto yields BLOCKED-ENV, which per the business section \"does not count against the repository\". The evaluators execute the candidate's own code (`crates/dream-core/src/candidate.rs:300`).", + "failure": "a candidate whose tests would fail instead makes one test sleep past the evaluator timeout. The outcome is TimedOut → harness → BLOCKED-ENV: the failure is never counted, and if hosts retry blocked nights the model gets unbounded attempts to pass.", + "confidence": "medium, inferred (mapping stated; the gaming payoff depends on unstated retry/accounting policy)", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:critical:F-07", + "kind": "finding", + "title": "Receipts are graded only when the model claims ACCEPT; every other claim keeps \"the model's own reading\", so REJECT exists mainly when the model accuses itself", + "topics": "GAT-01.2, GAT-01 (For the business)", + "evidence": "Only the claimed-ACCEPT branch reaches candidate state and grading (`crates/dream-gate/src/gate.rs:405-418`); \"A claim that is not ACCEPT keeps the model's own reading, or INCONCLUSIVE if unreadable (`gate.rs:493`)\"; an unreadable verdict is an unproven veto (`gate.rs:407`) and skips grading entirely.", + "failure": "a model whose patch fails tests emits a garbage verdict or any non-ACCEPT claim. Its failing receipts are never consulted, the night is recorded as INCONCLUSIVE (or the model's own word), and the evidence class the business section promises (\"gathered and against the candidate\") is never gathered.", + "confidence": "high (bypass is stated; the deliberate-dodge framing is inferred)", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:critical:F-08", + "kind": "finding", + "title": "Environment breakages that exit non-zero are classed as evidence, contradicting the stated purpose of the class split", + "topics": "GAT-01.1, GAT-01 (For the business)", + "evidence": "\"Failed exit code, ExplicitFail marker → evidence veto (`crates/dream-gate/src/gate.rs:141-146`)\" → REJECT, while only missing/silent/blocked/timed-out are harness. The business section promises \"a broken test runner is recorded as an environment problem and does not count against the repository.\"", + "failure": "an OOM-killed test process, a missing toolchain, or a corrupted cache makes the evaluator exit non-zero on an unchanged repository. The night records REJECT, which counts against the repository — exactly the \"confuses the test machine was down with the change was bad\" failure the design claims to prevent, eroding trust in every REJECT record.", + "confidence": "medium, inferred (mapping stated; frequency of non-zero env failures is world knowledge)", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:critical:F-09", + "kind": "finding", + "title": "Non-protected guard violations get different verdict classes depending on which layer sees them, and the guard's own docs disagree with the primary path", + "topics": "GAT-01.2, GAT-01.3, GAT-01 (For the business)", + "evidence": "Marked Open: `candidate::run` maps Protected to evidence state but \"every other violation to Refused, which is unproven\" → INCONCLUSIVE (`crates/dream-core/src/candidate.rs:207`, `crates/dream-gate/src/gate.rs:437`); the gate's re-check maps \"every violation other than Protected to an evidence veto, patch-guard\" → REJECT (`gate.rs:353`); \"the guard's own docs tell callers to treat the non-protected violations as REJECT-class (`guard.rs:1160`)\". The business section says an absurdly large patch \"stops the night\", but the live path records it like a no-patch claim: INCONCLUSIVE.", + "failure": "the same TooLarge/malformed/symlink patch yields INCONCLUSIVE through `candidate::run` and REJECT on a replay under a changed policy or a caller that skipped the pre-apply check. Verdict statistics conflate classes; a hostile model prefers the INCONCLUSIVE path; auditors cannot reconstruct which class a refusal belonged to.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:critical:F-10", + "kind": "finding", + "title": "The lenient reader's \"never fails and over-approximates\" claim is unsupported for exactly the constructs strict mode rejects", + "topics": "GAT-01.5", + "evidence": "`evaluator_command_paths` \"never fails and over-approximates, and is kept for callers that are not freezing a night (`crates/dream-gate/src/guard.rs:703`)\", while strict mode errors on command substitution because `$(` or backticks make the touched-file set unknowable (`guard.rs:536`, `guard.rs:557`). A static reader that skips those constructs cannot enumerate the files they touch, so the approximation direction for them is downward, not upward.", + "failure": "a non-night live caller builds its policy with the lenient form for an evaluator like `cat $(ls fixtures)/setup.env`; the files actually read are not in the protected set; a patch modifies one of them and the guard passes.", + "confidence": "medium", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:critical:F-11", + "kind": "finding", + "title": "Strict refusal of command substitution anywhere outside single quotes blocks legitimate evaluator commands and stops the night outright", + "topics": "GAT-01.5", + "evidence": "Marked Tension: \"a legitimate evaluator such as `cargo test $(nproc)` stops the night as UnsupportedCommand (`crates/dream-gate/src/guard.rs:557`)\" and `guard.rs:169` makes this a \"the night must not run\" error.", + "failure": "an evaluator config containing any `$(…)`, backtick, `$(pwd)`, or shell-computed path — routine in real CI commands — prevents the nightly function from starting at all, with no override path except rewriting the frozen config; production evaluation is unavailable until a human edits configuration.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "yes (Tension)", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:critical:F-12", + "kind": "finding", + "title": "`complete_receipts` still trusts receipt self-fields, reintroducing the forged-requiredness escape for any caller that uses it", + "topics": "GAT-01.4", + "evidence": "Marked Debt: \"`complete_receipts` remains for callers holding only identities and trusts the receipt's own fields (`crates/dream-gate/src/gate.rs:157`); `decide` does not use it.\" The whole GAT-01.4 section exists because receipts self-describing requiredness let forgeries escape the veto set.", + "failure": "reporting, tooling, or a future call site uses `complete_receipts`; a receipt that marks itself advisory or renames its command drops out of the veto set there, and two components of the same system disagree about whether a night was valid.", + "confidence": "medium (existence and trust model stated; which callers use it is not shown)", + "severity": "medium", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:critical:F-13", + "kind": "finding", + "title": "The config-drift harness check runs only on the claimed-ACCEPT, tree-trusted path", + "topics": "GAT-01.2, GAT-01.5", + "evidence": "The digest check is step 3, behind `tree_trusted?` (`crates/dream-gate/src/gate.rs:461-471`) and behind claimed-ACCEPT (`gate.rs:405`); non-ACCEPT claims exit at `gate.rs:493` and guard refusals skip step 3. The only other drift guard is `ProtectedPolicy::for_manifest` refusing mismatched config bytes at policy-build time (`guard.rs:340`).", + "failure": "a decide-only caller or replay path that does not build its policy through `for_manifest` records verdicts under a drifted config with no harness veto, whenever the claim was not ACCEPT or the guard refused — drift is detected only on the happy path.", + "confidence": "medium (skipping is stated; which callers skip `for_manifest` is not)", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:critical:F-14", + "kind": "finding", + "title": "The prose assumes the guard \"said the patch did not touch the config\", but the config file is never listed among protected paths", + "topics": "GAT-01.2, GAT-01.5", + "evidence": "\"The digest check runs only after the guard has said the patch did not touch the config (`crates/dream-gate/src/gate.rs:467`)\" — yet the material's only protection sources are evaluator-command word paths (`guard.rs:586`, `guard.rs:661`), the config's `protected` globs, and host-resolved extras (`crates/dream-core/src/replay.rs:143`). Nowhere is the config file itself stated to be protected.", + "failure": "if the night's config lives in the repository and is not matched by a protected glob, a candidate patch edits it; the decide-time digest then differs, producing a harness veto (`gate.rs:471`) → BLOCKED-ENV: the candidate's own sabotage of the rules is recorded as an environment problem and not counted against it.", + "confidence": "low, inferred (the gap is between an asserted guard conclusion and the enumerated protection sources; whether config is in-repo is not shown)", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:critical:F-15", + "kind": "finding", + "title": "No after-use cleanup or crash recovery for worktrees is described; only removal-before-next-use", + "topics": "GAT-01.3", + "evidence": "The only lifecycle statement is \"any existing directory at that path is removed before use (`crates/dream-core/src/persist.rs:142`)\". No post-run cleanup, no crash-safe teardown, and no recovery of in-flight attempt state appears anywhere in the material.", + "failure": "a host crash or kill mid-night leaves `dream-wt-*` directories in the shared temp volume on every interruption; stale worktrees accumulate until a later run happens to reuse the identical repo+branch path, filling the temp filesystem and degrading every tenant of that volume.", + "confidence": "low, inferred (absence-based; persist.rs internals beyond the cited lines are not shown)", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:R-1", + "kind": "root-cause", + "title": "Candidate-authored code executes with the runner's privileges on the evaluation host", + "topics": "GAT-01.3", + "evidence": "The material states this outright as Debt: \"a candidate-added build script still runs with the runner's privileges; isolation is the host's job\" (`crates/dream-core/src/candidate.rs:296`). No isolation layer appears anywhere in the three flow diagrams; the only containment mechanism in the entire topic is protected-path checking of patch text. The policy is built from the *baseline* config (`crates/dream-core/src/candidate.rs:141`), so the candidate cannot weaken the rules — but the rules only constrain which paths a patch may touch, not what executed code may do.", + "failure": "A candidate patch adds or modifies a script that the fixed pipeline executes — a build entry file, a repo-local helper, anything the optional candidate build command or a required evaluator invokes. The guard checks only the *patch text* against protected paths (`crates/dream-core/src/candidate.rs:245`, `crates/dream-core/src/persist.rs:139`, `crates/dream-gate/src/gate.rs:420`), so adding new executable content is not itself a violation. The optional candidate build command then runs it (`crates/dream-core/src/candidate.rs:296`), followed by the required evaluators (`crates/dream-core/src/candidate.rs:300`), both through the runner, with the runner's privileges. The script reads what the runner can read — credentials for the git remote, other worktrees, receipt storage, the manifest — and exfiltrates or rewrites it.", + "confidence": "high that the defect exists (stated verbatim); the specific breach chain is inferred.", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:premortem:R-2", + "kind": "root-cause", + "title": "Unprotected indirect harness files let a candidate forge its own green light", + "topics": "GAT-01.5, GAT-01.1", + "evidence": "The Debt line names the gap exactly: \"the reader cannot see files an evaluator reads indirectly (a `Makefile` behind `make test`, a `conftest.py` behind `pytest`); those must be named in the config's `protected` globs\" (`crates/dream-gate/src/guard.rs:703`). The business text claims \"a test result that has been tampered with … is treated as a broken test machine\" — but the tamper detection described is receipt-versus-manifest (`crates/dream-gate/src/gate.rs:197`–`228`), which a harness-file tamper fully satisfies with a valid receipt. The silent-evaluator rule (`crates/dream-gate/src/gate.rs:128`) does not help: the tampered harness prints output.", + "failure": "A candidate patch modifies a file the evaluator reads *indirectly* — a `conftest.py` behind `pytest`, a `Makefile` target behind `make test`. The frozen command `pytest` contains no path-like words, so `word_path` derives no protection from it (`crates/dream-gate/src/guard.rs:586`); the file is not in the config's `protected` globs; the patch applies cleanly. The evaluator runs on the candidate tree (`crates/dream-core/src/candidate.rs:300`), auto-loads the tampered harness file, and the tampered harness neutralises the checks and produces normal-looking passing output. The receipt is genuine: the command digest matches the frozen manifest (`crates/dream-gate/src/gate.rs:218`). `veto_for` sees a `Passed` outcome and produces no veto (`crates/dream-gate/src/gate.rs:123`). `decide` finds an empty veto list with a claimed ACCEPT and returns ACCEPT (`crates/dream-gate/src/gate.rs:489`). The malicious payload rides the same patch into the repository.", + "confidence": "high that the protection gap exists (explicit); medium — inferred — that it produced a forged ACCEPT in production.", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:premortem:R-3", + "kind": "root-cause", + "title": "Predictable, pre-removed worktree path under the shared temp directory", + "topics": "GAT-01.3", + "evidence": "Stated as Debt: \"On a multi-user host another local user can pre-create or race that path; the patch file that used to sit beside it is gone, but the worktree directory is not yet unpredictable\" (`crates/dream-core/src/persist.rs:142`). The prior symlink fix covered only the patch file, by feeding verified bytes to `git apply` on stdin (`crates/dream-core/src/git.rs:23`, `crates/dream-core/src/persist.rs:168`) — showing this attack class was already realised once against this system. The digest is 8 hex characters, i.e. 32 bits, with no per-run salt.", + "failure": "The candidate worktree lands at `dream-wt-<8 hex of the repository path digest>-` under the shared temp directory, and \"any existing directory at that path is removed before use\" (`crates/dream-core/src/persist.rs:142`). Two failure modes follow. (a) Cross-user: another local user pre-creates or races that path; between the removal and the worktree creation the attacker controls the directory contents, and a checkout into a directory seeded with symlinks writes outside the tree — or the pre-removal itself deletes attacker-chosen content. (b) Same operator: two concurrent nights on the same repository and branch deterministically share the path — no hash collision needed, the branch component is predictable — and one run removes the other's in-flight worktree, producing a partial or wrong tree that is then evaluated and graded.", + "confidence": "high that the weakness exists (stated); the exploitation and self-collision scenarios are inferred (the material asserts removal and predictability, not the race outcome).", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:premortem:F-01", + "kind": "finding", + "title": "Legacy `complete_receipts` still trusts self-describing receipts", + "topics": "GAT-01.4", + "evidence": "`decide` now validates receipts against the frozen manifest because \"earlier the required set came from the receipts, so a forged receipt could take itself out of the veto set\" (`crates/dream-gate/src/gate.rs:197`, `crates/dream-gate/src/gate.rs:217`); yet \"`complete_receipts` remains for callers holding only identities and trusts the receipt's own fields (`crates/dream-gate/src/gate.rs:157`); `decide` does not use it.\"", + "failure": "Any caller other than `decide` — host summarisation, a replay path, a baseline precheck — grades through `complete_receipts`, and a receipt that downgrades itself to advisory or omits itself escapes the veto set: exactly the forgery hole that was fixed, still shipped at a second entry point.", + "confidence": "high that the legacy path exists and trusts receipt fields; medium — inferred — that a production caller invokes it.", + "severity": "high", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:premortem:F-02", + "kind": "finding", + "title": "Files written by the candidate build step are never guard-checked", + "topics": "GAT-01.3", + "evidence": "Every guard check in the system operates on patch text — before any git command (`crates/dream-core/src/candidate.rs:245`), on the normalised text in the worktree build (`crates/dream-core/src/persist.rs:139`), and again inside `decide` (`crates/dream-gate/src/gate.rs:420`). The optional candidate build command executes candidate-influenced code on the candidate tree *before* the evaluators run (`crates/dream-core/src/candidate.rs:296` → `crates/dream-core/src/candidate.rs:300`).", + "failure": "Even an operator who followed the R-2 mitigation and listed `conftest.py` in the protected globs is exposed: the build step overwrites or generates the harness file at build time. The change exists in no patch, so no guard layer ever sees it, and the evaluators load it.", + "confidence": "medium — inferred (the order is asserted by the sequence diagram; the unchecked-write consequence is my inference).", + "severity": "high", + "marked_by_authors": "no (the Debt at `candidate.rs:296` covers privilege, not the write channel)", + "status": "unverified", + "shard": "gate", + "high": true + }, + { + "id": "gate:premortem:F-03", + "kind": "finding", + "title": "Guard refusal skips receipt grading, so REJECT can mask real harness faults", + "topics": "GAT-01.2", + "evidence": "\"When the re-check refuses the patch, `tree_trusted` goes false and step 3 is skipped, so the candidate's receipts are not graded. Harness faults outrank evidence in general, but a refused patch skips grading, so a guard veto can never be diluted into `BLOCKED-ENV`\" (`crates/dream-gate/src/gate.rs:500`).", + "failure": "A replay under a changed policy (explicitly reachable — see the Open text) or any environment fault coincident with a refused patch yields REJECT attributed to the candidate, while the genuine harness faults are never even collected. The operator blames the model for what is operator or environment drift — the exact distrust spiral the business section warns against.", + "confidence": "high that the masking occurs (it is the stated mechanism); medium — inferred — for its operational consequence.", + "severity": "medium", + "marked_by_authors": "no (the authors present the skip as a feature)", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-04", + "kind": "finding", + "title": "Non-Protected guard violations end the night INCONCLUSIVE, not REJECT, in the standard flow", + "topics": "GAT-01.3", + "evidence": "\"Before apply, `candidate::run` maps `Protected` to the evidence state but every other violation to `Refused`, which is unproven (`crates/dream-core/src/candidate.rs:207`, `crates/dream-gate/src/gate.rs:437`) … a refused patch therefore ends `INCONCLUSIVE`\", while the guard's own docs \"tell callers to treat the non-protected violations as `REJECT`-class for consistent verdicts (`crates/dream-gate/src/guard.rs:1160`)\".", + "failure": "Symlink, escape, binary and too-large probing by a model is recorded as \"nothing to test\". Alerting or triage keyed on REJECT never sees security-relevant attempts, and verdict statistics undercount hostile behaviour.", + "confidence": "high (stated behaviour).", + "severity": "medium", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-05", + "kind": "finding", + "title": "Verdicts are not reproducible under a changed replay policy", + "topics": "GAT-01.2, GAT-01.5", + "evidence": "The invariant claims \"the live guard, the gate and a later replay agree (`crates/dream-core/src/replay.rs:173`)\", yet the Open text states the REJECT mapping \"is reached … by a caller that skipped the pre-apply check or a replay under a changed policy\".", + "failure": "After an incident, an auditor replays a night with edited protected globs; the same frozen inputs produce REJECT where the live run produced ACCEPT (or the reverse). The post-incident record of what the gate allowed diverges from what it actually allowed.", + "confidence": "medium-high (both statements are in the material; the contradiction between them is my observation).", + "severity": "medium", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-06", + "kind": "finding", + "title": "The gate re-check's input is described inconsistently: patch text vs applied text", + "topics": "GAT-01.2, GAT-01.3", + "evidence": "GAT-01.2 says the gate runs \"guard::check on the patch text again (`crates/dream-gate/src/gate.rs:420`)\"; GAT-01.3's sequence says \"guard check once more on the applied text\" at the same citation. Meanwhile normalisation \"synthesises headers and could name a path the original did not\" (`crates/dream-core/src/persist.rs:127`), and only the worktree build re-checks the normalised text (`crates/dream-core/src/persist.rs:139`). The gate re-check is \"the second line for callers who skipped the first\" (`crates/dream-gate/src/gate.rs:367`).", + "failure": "If the gate checks pre-normalisation text, a caller that skipped `candidate::run`/persist but still reports an `Applied` state gets a synthesised protected path — e.g. into the config — past the last line of defence, and the digest check then misattributes the resulting drift.", + "confidence": "low-medium — inferred (documentation ambiguity; cannot be resolved from the material).", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-07", + "kind": "finding", + "title": "Lenient command reader is still shipped and never fails", + "topics": "GAT-01.5", + "evidence": "The strict reader exists because a command that \"cannot be read with certainty is an error rather than an approximation\" (`crates/dream-gate/src/guard.rs:169`, `crates/dream-gate/src/guard.rs:489`); the lenient \"`evaluator_command_paths`, never fails and over-approximates, and is kept for callers that are not freezing a night (`crates/dream-gate/src/guard.rs:703`)\".", + "failure": "A live tooling path uses the lenient form; a command containing `$(...)` or unusual quoting yields a guessed path set, an evaluator entry file is left unprotected, and the R-2 chain proceeds through the lenient reader.", + "confidence": "low — inferred (the callers are unnamed).", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-08", + "kind": "finding", + "title": "`word_path` can protect coincidental baseline names", + "topics": "GAT-01.5", + "evidence": "Protection derives from \"path-like words and known baseline files\" (`crates/dream-gate/src/guard.rs:586`).", + "failure": "An evaluator word coincides with a baseline file name; a legitimate patch touching that file trips a spurious `Protected` violation and the night records REJECT against a good change.", + "confidence": "low — inferred.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-09", + "kind": "finding", + "title": "Provenance of `tree_trusted` is unstated", + "topics": "GAT-01.2", + "evidence": "The flow gates step 3 on \"tree_trusted?\" (`crates/dream-gate/src/gate.rs:461`) immediately after the gate's own re-check, but nothing in the material states who computes the flag or from what; persist returns only \"tree hash and applied patch\".", + "failure": "If the flag arrives from caller-supplied state, a forged false skips receipt grading and hides genuine harness faults behind an INCONCLUSIVE — the verdict stays fail-safe, but the record is wrong.", + "confidence": "low — inferred (absence of provenance in the material).", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-10", + "kind": "finding", + "title": "Legitimate evaluators using command substitution stop every night", + "topics": "GAT-01.5", + "evidence": "\"command substitution is refused wherever it is not in single quotes, so a legitimate evaluator such as `cargo test $(nproc)` stops the night as `UnsupportedCommand` (`crates/dream-gate/src/guard.rs:557`)\".", + "failure": "A repository whose test command uses substitution blocks every night as BLOCKED-ENV until someone rewrites the evaluator; sustained unavailability of the pipeline.", + "confidence": "high (stated).", + "severity": "low", + "marked_by_authors": "yes (Tension)", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-11", + "kind": "finding", + "title": "Resource bounds make large-but-legitimate patches permanently ungateable, and give the model a cheap kill switch", + "topics": "GAT-01.5", + "evidence": "Defaults of 4 MiB total, 2000 files, 8192-byte header lines (`crates/dream-gate/src/guard.rs:192`); \"Any bound exceeded fails closed (`crates/dream-gate/src/guard.rs:963`)\"; a refused patch ends the night INCONCLUSIVE via `Refused` (`crates/dream-core/src/candidate.rs:207`, `crates/dream-gate/src/gate.rs:437`).", + "failure": "A legitimate vendored dependency update above the bounds always ends INCONCLUSIVE; symmetrically, a model can end any night early and cheaply by emitting an oversized patch.", + "confidence": "medium (bounds and mapping stated; the operational impact is inferred).", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-12", + "kind": "finding", + "title": "Only a binary *delete* refusal is documented at the candidate layer", + "topics": "GAT-01.3", + "evidence": "The single documented binary refusal is \"refuse a binary delete (`crates/dream-core/src/candidate.rs:237`)\"; nothing states how binary additions or modifications are handled at that layer or in the gate's re-check.", + "failure": "If binary add/modify is treated differently across the three guard layers, a binary blob rides a patch into the tree, or the same patch draws different verdicts depending on which layer sees it.", + "confidence": "low — inferred (an absence in the material, not an asserted gap; to be checked against the code).", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "gate:premortem:F-13", + "kind": "finding", + "title": "Advisory receipts' failures produce no signal", + "topics": "GAT-01.4", + "evidence": "A receipt for a name the manifest does not require \"is kept but forced advisory, so it cannot promote itself into the veto set (`crates/dream-gate/src/gate.rs:208`)\"; grading via `veto_for` covers the validated required set (`crates/dream-gate/src/gate.rs:121`), and nothing grades advisory outcomes.", + "failure": "A real check that has drifted out of the manifest — renamed, misconfigured — still runs, still fails, and the night ACCEPTs anyway; the team believes coverage exists that the gate never evaluated.", + "confidence": "low-medium — inferred.", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "gate", + "high": false + }, + { + "id": "nightly:critical:F-01", + "kind": "finding", + "title": "The automerge gate treats a required-check *name* as protection; nothing requires reviews or protects the checks' own definitions", + "topics": "NIT-03.1, NIT-03.2", + "evidence": "The tree's only protection test is the required-check list: \"list empty or blank\" → `NoRequiredChecks`, \"at least one name\" → `Allowed` (`crates/dream-core/src/automerge.rs:126`), and the facts `publish` gathers are only the PR base branch and that list (`crates/dream-core/src/persist.rs:314`, `persist.rs:316`). No branch of the tree consults required-pull-request-reviews or any property of the checks beyond name existence. The business section claims eligibility requires \"branch protection with required checks\" (`NIT-03`, For the business). But the check *workflows* a required name refers to normally live inside the very repository the candidate patch modifies — the protected-path guard exists precisely because candidates can modify repo content (ADR-DE-003, governing NIT-02) — and the decision tree never verifies that the named checks' definitions are protected.", + "failure": "A repo opts in with one required check and zero required reviews. The nightly candidate weakens the workflow that the required check runs (unless that repo's protected-path config happens to cover it), the check goes green, and `gh pr merge --auto` merges unreviewed, self-certified model-written code to the default branch unattended.", + "confidence": "high that the tree checks names only (the diagram is presented as the whole decision); the workflow-redefinition exploit is inferred", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:critical:F-02", + "kind": "finding", + "title": "A crash after `Persisted` silently loses the publish, and the restart records `Complete` without asking the forge", + "topics": "NIT-01.1, NIT-01.3, NIT-03.2", + "evidence": "`Persisted` is journalled before the draft pull request and ledger row (`crates/dream-core/src/runstate.rs:72`, `runstate.rs:615`), and \"the restart finalises the night to `Complete` without reconciling against the forge\" (`crates/dream-core/src/runstate.rs:401`). \"Nothing records the push, the pull request URL or the ledger commit afterwards\" (`NIT-03.2`, Debt), and the ledger idempotency key is \"for any future reconciliation\" — i.e., none exists today (`crates/dream-core/src/persist.rs:298`).", + "failure": "Engine crashes (or is OOM-killed) between journalling `Persisted` and writing the ledger row. Restart returns `AlreadyComplete`, flips `Persisted`→`Complete`, and nothing anywhere flags the missing PR or ledger row. Worse: if `enable_automerge` already succeeded, the forge merges the PR asynchronously after the engine is dead — the merge lands with no URL, no ledger row, and a journal that says the night simply finished. Recovery depends on a human noticing and running `reset_night`.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "yes (Tension in NIT-01.1; Debt in NIT-03.2)", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:critical:F-03", + "kind": "finding", + "title": "An *absent* journal reads as Fresh: losing the one custody file re-arms a possibly-published night and resets its attempt budget", + "topics": "NIT-01.2, NIT-01.1", + "evidence": "The hardening made every unreadable journal an error, but \"absence is the only thing that reads as fresh\" (`crates/dream-core/src/runstate.rs:341`), and the stated reason corrupt must not read fresh is that it \"reopens a finished one and resets its attempt budget\" (`crates/dream-core/src/runstate.rs:333`) — exactly the consequence of a *deleted or lost* journal on a finished night, which the same rule treats as benign. `begin`'s Fresh path consults neither the forge nor the ledger.", + "failure": "A sync tool, mis-provisioned volume, or operator `rm` removes the run-state file of a night that already published. Next start: absent journal → `Fresh`, attempts 1 → the full pipeline re-runs and publishes a second PR and a second ledger row. The entire never-repeat-a-publish guarantee rests on a single file whose loss mode is the dangerous one, not the safe error.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:critical:F-04", + "kind": "finding", + "title": "\"A NightLock is held across the whole night\" is stated as delivered, yet no shipped code acquires it", + "topics": "NIT-01.4, NIT-01 (intro, business)", + "evidence": "The intro says \"Since the audit hardening … A `NightLock` is held across the whole night (`crates/dream-core/src/runstate.rs:248`)\" and the business section asserts \"Two copies of the engine cannot work the same night at once.\" NIT-01.4's own Open marker contradicts both: \"`begin` does not take it and nothing else in this repository calls `NightLock::acquire`, so an engine that skips it is not stopped here (`crates/dream-core/src/runstate.rs:261`)\". The crate \"ships the journal, not the driver\" (`crates/dream-core/src/lib.rs:21`).", + "failure": "Two engine processes (a cron overlap, a manually launched copy, a scheduler retry) both read an absent or pre-`Persisted` journal on the same night, both run to completion, both publish. The audit-fix narrative says the exclusion problem was solved; in the shipped code it is a comment.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "yes (Open in NIT-01.4) — but the contradiction with the intro and business claims is unflagged", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:critical:F-05", + "kind": "finding", + "title": "Exclusion is a per-directory `flock`; nothing coordinates two workers on two directories or hosts", + "topics": "NIT-01.3, NIT-01.4, NIT-03.2", + "evidence": "The lock is \"`flock`-style on `/.lock`\" (`crates/dream-core/src/runstate.rs:274`) and \"a night is identified by its directory\" (ADR-DE-007, `crates/dream-core/src/runstate.rs:7`). The night key `(repo, date, night_id)` is named only as the idempotency key \"for any future reconciliation\" (`NIT-03.2`, Debt) — no current mechanism keys forge or ledger writes globally.", + "failure": "Two workers (two hosts, or one host with two checkout directories) run the same repo/date/night. Neither lock nor journal is shared; each sees an absent journal, each returns `Fresh`, each publishes. The business guarantee holds for one directory and fails for two.", + "confidence": "medium; inferred — deployment topology is not stated, but no cross-directory mechanism appears anywhere in the material", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:critical:F-06", + "kind": "finding", + "title": "The persist-before-effects ordering is a caller contract; violation converts at-most-once into duplicate publishes", + "topics": "NIT-01.1, NIT-01.4, NIT-03.2", + "evidence": "\"the at-most-once ordering is a documented caller contract on `persist` … no code in this repository sequences it, so nothing here enforces it\" (`crates/dream-core/src/runstate.rs:615`). Resume re-walks earlier steps (`runstate.rs:604`) with no per-night idempotency on the push or model call, and the ledger key is future work.", + "failure": "A host that opens the PR before journalling `Persisted` (or whose model call has external side effects before persist) crashes: restart resumes at `Gated`/`CandidateEvaluated`, re-walks, and repeats the external effect. The design's headline property — never repeat a publish — is only as strong as unshipped, unverified driver code.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "yes (Tension in NIT-01.1)", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:critical:F-07", + "kind": "finding", + "title": "Replay verification is not tamper evidence: only two of the seven artifacts are digest-bound", + "topics": "NIT-02 (intro), NIT-02.2", + "evidence": "\"Only the report and the config are digest-bound, so editing a receipt's exit status, outcome and streams together with `decision.json` defeats `verify`\" (`crates/dream-core/src/replay.rs:10`); verify's receipt check is self-consistency — re-classifying each receipt \"from its exit status and streams\" (`replay.rs:525`) — not independence.", + "failure": "Anyone with write access to a run directory rewrites a failing receipt's exit status, outcome and streams plus the recorded decision; `verify` re-derives the forged verdict and passes. Any audit process that treats `verify` passing as integrity gets false assurance; the stated remedy (\"a witness over the run directory\") \"lives elsewhere\" (`replay.rs:18`) and is not shown to exist.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "yes (Tension in NIT-02)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:critical:F-08", + "kind": "finding", + "title": "`capture` is never invoked by the shipped system, so \"any verdict can be re-derived\" is conditional on unverified host behaviour", + "topics": "NIT-02 (business), NIT-02.1", + "evidence": "Business: \"Any verdict the nightly engine gave can be re-derived from the saved evidence\" (`NIT-02`, For the business). Open marker: \"nothing in this repository calls `capture` during a night. The host driver has to, so whether any real night is replayable depends on the host\" (`NIT-02.1`). Combined with F-02: capture happens (if at all) after the gate phase, so the nights most in need of scrutiny — ones that crashed — are the least likely to have evidence.", + "failure": "Production runs for months with no host-side capture call; an incident demands replay, and there is nothing to replay. Nothing in the tooling detects the omission, because absence of a run directory is not an error anywhere.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "yes (Open in NIT-02.1) — the contradiction with the business claim is unflagged", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:critical:F-09", + "kind": "finding", + "title": "The lock can be held hostage by descriptors inherited into forked children, with no force path for the operator", + "topics": "NIT-01.3, NIT-01.4", + "evidence": "The lock is per open file description (`crates/dream-core/src/runstate.rs:274`), and `acquire_within` \"retries for a short while because a descriptor briefly duplicated into a forked child can make a just released lock look held\" (`runstate.rs:295`) — i.e., the fd survives into children and the mitigation assumes the child is short-lived. `reset_night` \"waits at most 500 ms\" and fails `WouldBlock` (`runstate.rs:442`), and no force or steal operation appears in the material.", + "failure": "A hung evaluator or `gh` subprocess spawned during a night inherits the lock fd and outlives the engine. The next night's start fails `Held`; the operator remedy for a blocked night (`reset_night`) also fails `WouldBlock` within 500 ms; recovery requires finding and killing an anonymous child nobody is told about.", + "confidence": "medium; inferred — child lifetime and fd flags are not stated, but the stated fork problem implies inheritance", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:critical:F-10", + "kind": "finding", + "title": "Verify re-judges the decision but never re-runs the evaluation; honest-but-wrong evidence verifies clean", + "topics": "NIT-02 (business), NIT-02.2", + "evidence": "`decide` \"build[s] the policy, parse[s] the report strictly, call[s] gate::decide\" over stored inputs (`crates/dream-core/src/replay.rs:305`); the three checks are the recomputed decision, receipt self-consistency, and the patch-vs-state guard (`replay.rs:485`). Receipts are never re-executed and nothing binds them to a checkout state (only report and config are digested, `replay.rs:10`). Business claims verify \"catches accidents and drift\" (`NIT-02`, For the business) — but only decision-layer drift.", + "failure": "A flaky or non-deterministic evaluator suite, or a host bug that ran the evaluator against the wrong tree, records receipts that are internally consistent and faithfully re-judged: `verify` passes and certifies a verdict whose evidence was wrong from the start.", + "confidence": "medium; inferred scope limit from what verify is documented to do", + "severity": "medium", + "marked_by_authors": "no (adjacent to, but distinct from, the marked tamper Tension)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:critical:F-11", + "kind": "finding", + "title": "The entire forge path is exercised only against fakes before production", + "topics": "NIT-03.2", + "evidence": "\"The only test touching `GhForge` is a dyn-safety check (`crates/dream-core/src/seams.rs:647`), so the protection parsing, the draft-to-ready transition and the squash request have only been exercised against fakes.\"", + "failure": "The first live nights are the first live test. Failure modes include protection responses parsed into the wrong refusal bucket, the draft-to-ready transition failing or firing at the wrong moment, and the squash request erroring — each discovered as a broken PR at merge time rather than in CI.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "yes (Tension in NIT-03.2)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:critical:F-12", + "kind": "finding", + "title": "An `enable_automerge` failure is terminal: no retry, PR left as draft, only an `automerge_error` field knows", + "topics": "NIT-03.2", + "evidence": "\"a forge failure after `Allowed` is recorded in `automerge_error` and the pull request stays a draft … there is no retry\" (`crates/dream-core/src/persist.rs:327`).", + "failure": "A transient GitHub 5xx during the one enable call permanently degrades an opted-in repo to manual merges; the PR sits as a draft with no scheduled retry and (per F-02) no reconciliation on restart. Users who trusted unattended merges find stale drafts accumulating.", + "confidence": "high", + "severity": "medium", + "marked_by_authors": "yes (Open in NIT-03.2)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:critical:F-13", + "kind": "finding", + "title": "`reset_night`'s all-or-nothing rollback covers returned errors, not process death; a mid-reset crash produces a journal-free night", + "topics": "NIT-01.3, NIT-01.2", + "evidence": "The sequence is hard-link both archives, then remove both sources, with rollback \"if the second archive cannot be made the first is deleted, and if a source cannot be removed the already removed ones are linked back\" (`crates/dream-core/src/runstate.rs:468`) — rollback is failure-return-driven. The intro sells reset as a two-step archive that \"cannot collide\" and archives \"both files or neither\" (`NIT-01.3`). A crash after removing the run-state source but before rollback leaves no journal, which `begin` reads as `Fresh` with attempts 1 (`runstate.rs:540`) — contradicting the night-level attempt budget claim (`runstate.rs:567`) and feeding directly into F-03's dangerous absent-reads-fresh mode.", + "failure": "Operator resets a bad night; the process dies between the two removals. The next engine start treats the night as brand new with a full budget, silently, with no record that a half-finished operator action occurred.", + "confidence": "medium; inferred — the crash windows follow from the stated step order, but the write durability is unstated", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:critical:F-14", + "kind": "finding", + "title": "Two governing passages disagree on whether any code sequences the persist ordering", + "topics": "NIT-01.1, NIT-03.2", + "evidence": "NIT-01.1 (Tension): \"no code in this repository sequences it, so nothing here enforces it\" (`crates/dream-core/src/runstate.rs:615`). NIT-03.2 (Debt): \"`Persisted` is journalled before the forge request (`crates/dream-core/src/persist.rs:298`)\" — a line inside this repository's `publish`, immediately before `publish_draft` at `persist.rs:305`. Either `dream-core`'s `publish` does sequence journal-then-forge (and NIT-01.1 is wrong), or it does not (and NIT-03.2 misdescribes the code).", + "failure": "A maintainer trusting one document refactors the publish path (or skips adding sequencing in a new driver) believing the other document's claim about where the safety ordering lives; the at-most-once property is silently altered. For a system whose value proposition is doc-to-code fidelity, a contradiction on the single most safety-critical ordering is itself a defect.", + "confidence": "high that the statements conflict as written; medium that runtime behaviour differs", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:critical:F-15", + "kind": "finding", + "title": "The self-referential refusal is slug/config/checkout-contains; which checkout and what \"contains\" means are unstated", + "topics": "NIT-03.1", + "evidence": "The three-part test is \"the slug, a config whose protected set covers the gate crates, and a checkout that contains them\" (`crates/dream-core/src/automerge.rs:136`, `automerge.rs:144`). The material never says whether the checkout inspected is the base or the post-patch state, nor how containment is matched (paths, crate names, dependencies). A candidate patch that *introduces* or *references* engine crates — rather than sitting in a checkout that already contains them — may not trip the third part.", + "failure": "A patch that adds gate crates as dependencies (or into paths the matcher does not cover) to a repo whose slug and protected config don't match is judged `Allowed`; the \"never to the engine itself\" promise in the business section is narrower in code than in prose.", + "confidence": "low; inferred — the material does not specify the checkout side or the matching rule", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:R-1", + "kind": "root-cause", + "title": "A crash after the journal says `Persisted` silently swallows an accepted change; the restart reports the night `Complete` without asking the forge", + "topics": "NIT-01.1, NIT-01.3, NIT-01.4, NIT-03.2", + "evidence": "`Persisted` is \"deliberately entered first, so a crash can lose a publish but never repeat one\" (runstate.rs:72); \"Nothing records the forge push or the ledger commit either, so a crash after `Persisted` loses that night's publish and the restart finalises the night to `Complete` without reconciling against the forge\" (runstate.rs:615, runstate.rs:401); the debt restates the windows — \"whether before the pull request exists or after it exists but before its URL or ledger row is recorded\" (persist.rs:298, runstate.rs:615); finalisation \"is best effort and the answer does not depend on the write landing\" (runstate.rs:419); the business text concedes it: \"The price of never repeating a publish is that a crash at the wrong moment can lose one.\"", + "failure": "night gates to ACCEPT → host calls `persist` → journal advances to `Persisted` **before** any external effect → the process dies (reboot, OOM, kill) in one of the three documented windows: before `publish_draft`, after the push but before the PR URL is recorded, or before the ledger row is written → nothing anywhere records the forge push or the ledger commit → restart calls `begin`; `finished_night` is night-level and ignores the run id, so the answer is `AlreadyComplete`, and `Persisted` is finalised to `Complete` best-effort, without reconciling against the forge → the client's accepted change never ships while every record says the night succeeded; the only recovery is a manual operator `reset_night`, and detection depends entirely on a human noticing the absence of a PR.", + "confidence": "high (the loss window and the non-reconciling restart are stated, not inferred).", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:premortem:R-2", + "kind": "root-cause", + "title": "The night lock is a documented contract that nothing enforces; two engines can work one night and both publish", + "topics": "NIT-01.4, NIT-01.2", + "evidence": "the lock \"spans check, freeze, execute and persist, so two engines cannot both read an absent journal and both publish\" (runstate.rs:248) — the stated protection is the lock and only the lock; the Open note that `begin` does not take it and no in-repository caller exists (runstate.rs:261); the resumed path that would tell a second engine to proceed (runstate.rs:549, runstate.rs:557); the unused idempotency key (NIT-03.2 Debt).", + "failure": "a deployment overlap (cron jitter, a retry after a page, a second host) starts two engine processes on the same night directory → `begin` does not take the `NightLock` and \"nothing else in this repository calls `NightLock::acquire`, so an engine that skips it is not stopped here\" → either both engines read an absent journal (both `Fresh`), or the later one reads the earlier's `Initialised` record and is told `Resumed` — and both `Fresh` and `Resumed` \"say proceed\" (runstate.rs:154) → both run the night, both `persist`, both publish; the night key is not used as a forge idempotency key today → on an automerge-opted repo whose facts satisfy `decide`, both pull requests can merge: two divergent candidates from one night land unreviewed.", + "confidence": "medium (inferred — the unenforced contract is stated; the two-engine deployment is reasoned beyond the material).", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:premortem:R-3", + "kind": "root-cause", + "title": "Automerge can be `Allowed` by forge-protection parsing that has never run against live GitHub", + "topics": "NIT-03.1, NIT-03.2", + "evidence": "the authored Tension (seams.rs:647; NIT-03.2); the enumerated fail-safe directions that presume the parser's non-empty answers are true (automerge.rs:13); the publish flow that queries protection once and hands the result to `decide` then `enable_automerge` (persist.rs:316, persist.rs:329, persist.rs:332).", + "failure": "client repo opts into `auto_merge` in the protected config, with branch protection and required checks → a night ACCEPTs, `publish_draft` returns pushed plus a PR url (clean draft), `pr_base_branch` matches the expected base → `required_status_checks` parses the live GitHub response — code whose only test is a dyn-safety check, with \"the protection parsing, the draft-to-ready transition and the squash request… only exercised against fakes\" → twelve months of API drift or a parsing defect yields a **non-empty list of names** for a branch whose protection is actually off or empty — the one direction the policy does not defend against, because error, 404, unsupported and empty all refuse (automerge.rs:13, seams.rs:377) while a fabricated non-empty list does not → `decide` returns `Allowed`, `enable_automerge` fires, and the PR merges with no required checks having run: an unreviewed, unverified change lands on the client's branch.", + "confidence": "medium (inferred — the missing live testing is stated; the false-non-empty direction is reasoned).", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:premortem:F-01", + "kind": "finding", + "title": "Coordinated edits defeat replay verification; only the report and config are digest-bound", + "topics": "NIT-02.2", + "evidence": "\"Only the report and the config are digest-bound, so editing a receipt's exit status, outcome and streams together with `decision.json` defeats `verify`\" (replay.rs:10); `verify` re-classifies receipts from their **stored** exit status and streams (replay.rs:525) and diffs against the recorded `decision.json` (replay.rs:413) — every link in that chain is a writable file.", + "failure": "anyone with write access to a run directory rewrites a failing receipt to pass, adjusts `decision.json` to match, and `verify` certifies a change that never passed its checks.", + "confidence": "high (stated).", + "severity": "high", + "marked_by_authors": "yes (Tension)", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:premortem:F-02", + "kind": "finding", + "title": "The persist-before-publish ordering is as unenforced as the lock", + "topics": "NIT-01.1, NIT-01.4", + "evidence": "the at-most-once ordering is \"a documented caller contract on `persist`… no code in this repository sequences it, so nothing here enforces it\" (runstate.rs:615, runstate.rs:72); NIT-01.4's Open groups it with the lock as a host contract (runstate.rs:261).", + "failure": "a host that opens the draft PR before calling `persist` crashes after the push; the restart resumes at `Gated` (same run id — the ledger commit never happened), the host re-walks to `persist` and publishes again: the double publish the ordering exists to prevent.", + "confidence": "high that it is unenforced; the violating host is inferred.", + "severity": "high", + "marked_by_authors": "yes (Tension)", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:premortem:F-03", + "kind": "finding", + "title": "`reset_night` recovery can re-run a night whose pull request already exists", + "topics": "NIT-03.2, NIT-01.3", + "evidence": "the loss window includes \"after it exists but before its URL or ledger row is recorded\" (persist.rs:298); \"Recovery is the operator's `reset_night`, which archives the journal so the night runs again\" (runstate.rs:441) and nothing reconciles with the forge first.", + "failure": "a lost-publish night is reset; the unrecorded orphan draft PR still exists on the forge; the re-run pushes a second PR for the same night, and on an automerge repo both can merge divergent candidates.", + "confidence": "medium (inferred — the duplicate is implied by no-reconciliation plus reset, not stated outright).", + "severity": "medium", + "marked_by_authors": "yes (Debt marks the non-reconciliation; the duplicate consequence is my extension)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-04", + "kind": "finding", + "title": "Nothing in this repository calls `capture` during a night", + "topics": "NIT-02.1, NIT-02.2", + "evidence": "\"nothing in this repository calls `capture` during a night. The host driver has to, so whether any real night is replayable depends on the host\" (NIT-02.2 Open); there is also no historical corpus (replay.rs:20).", + "failure": "an incident investigation reaches for the evidence chain and finds no `replay.json`/`decision.json` because the host never captured; the business promise that any verdict can be re-derived is unbacked for production nights.", + "confidence": "high (stated).", + "severity": "medium", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-05", + "kind": "finding", + "title": "`capture` destroys the prior receipts before writing the new ones", + "topics": "NIT-02.1", + "evidence": "\"receipts/baseline and receipts/candidate, cleared first\" (replay.rs:348), justified by restarted nights having fewer receipts (replay.rs:340); \"Nothing is written if judging fails\" (replay.rs:333).", + "failure": "`capture` clears both receipt directories, then judging fails or the process dies mid-capture; the only copies of the night's raw evaluation evidence are gone — for this attempt and any earlier one.", + "confidence": "medium (inferred — the order is stated, the loss on interruption follows).", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-06", + "kind": "finding", + "title": "`protected_paths` in `replay.json` is not digest-bound; the replayed policy can be silently downgraded", + "topics": "NIT-02.1, NIT-02.2", + "evidence": "the policy extras, including `protected_paths`, are recorded in `replay.json` (replay.rs:367, replay.rs:111) and applied on replay (replay.rs:173); the only digest-bound files are the report and the config (replay.rs:10).", + "failure": "deleting or shrinking the `protected_paths` array makes `verify` judge under a weaker policy; if the stored patch happens not to touch those paths the recomputed decision still matches, and `verify` certifies a night under a policy it did not run.", + "confidence": "medium (inferred — a composition of two stated facts).", + "severity": "medium", + "marked_by_authors": "yes (the digest-binding gap is the authored Tension; this consequence is mine)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-07", + "kind": "finding", + "title": "`verify` re-derives, it never re-measures: evaluator receipts are trusted as inputs", + "topics": "NIT-02.2", + "evidence": "\"`verify` re-judges from the stored inputs\" (replay.rs:6); its receipt check re-classifies \"from its exit status and streams\" (replay.rs:525) — stored values, not re-execution; only the guard is re-run, on stored artifacts (replay.rs:528).", + "failure": "the evaluator harness records a wrong exit status at capture time (a bug, not tamper); `verify` recomputes the same wrong verdict and reports a match — the business claim that verify \"catches accidents\" holds for file accidents after capture, not for capture-time measurement errors.", + "confidence": "medium (inferred).", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-08", + "kind": "finding", + "title": "A night's automerge behaviour is absent from both the journal and the replay evidence", + "topics": "NIT-03.2, NIT-02.1, NIT-01.1", + "evidence": "the `Phase` ladder ends at `Gated`/`Persisted`/`Complete` (runstate.rs:58); `capture`'s seven files include no automerge record (replay.rs:336–379); a forge failure after `Allowed` lives only in `automerge_error` on the publish outcome (persist.rs:327).", + "failure": "after a disputed unattended merge, the run directory and journal cannot say whether automerge was allowed, enabled or refused, or why; the audit depends on unstated host handling of a return value.", + "confidence": "medium (inferred).", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-09", + "kind": "finding", + "title": "Branch protection is read once at publish; nothing re-validates before the auto-merge executes", + "topics": "NIT-03.2, NIT-03.1", + "evidence": "`required_status_checks` is queried once, for \"the branch the PR really targets\", during `publish` (persist.rs:314–316) and the result goes to `decide` then `enable_automerge` (persist.rs:329, persist.rs:332); no later phase or check exists in the ladder, and there is no retry (NIT-03.2 Open).", + "failure": "between the decision and the merge firing, the PR is retargeted or protection is relaxed; the merge proceeds under protection facts that no longer hold. (The material does not assert the timing between `enable_automerge` and the merge; the single read is what is stated.)", + "confidence": "low (inferred).", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-10", + "kind": "finding", + "title": "The only recovery path is designed to fail under a live engine, inviting kills that land in the loss window", + "topics": "NIT-01.3, NIT-01.4", + "evidence": "`reset_night` \"takes the night lock and waits at most 500 ms, so it cannot run under a live engine\" (runstate.rs:442); the lock is held \"across the whole night\" (runstate.rs:248); the crash-after-`Persisted` loss is the documented cost (runstate.rs:615).", + "failure": "an operator working a lost publish finds `reset_night` returning `WouldBlock` because the engine holds the lock for hours; the practical workaround — killing the engine — is precisely the crash that loses the next publish.", + "confidence": "low (inferred).", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-11", + "kind": "finding", + "title": "Which tree the host resolves evaluator-named protected paths against is unstated", + "topics": "NIT-02.1", + "evidence": "`protected_paths` are \"the evaluator-named files the host resolved against the repository\" (replay.rs:111); the material never says baseline or candidate state, while the guard itself runs against the candidate.", + "failure": "if resolution happens against the candidate-modified tree, a candidate that moves or shadows an evaluator-named path changes what gets protected; the live guard and every replay then faithfully protect the wrong set.", + "confidence": "low (inferred — an ambiguity in the material, not an observed defect).", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": true + }, + { + "id": "nightly:premortem:F-12", + "kind": "finding", + "title": "Two naming schemes for superseded manifests", + "topics": "NIT-01.3", + "evidence": "the Open note: \"the manifest archive on a moved baseline is named by run id (manifest.rs:249), the reset archive by timestamp (runstate.rs:452); two naming schemes for superseded documents.\"", + "failure": "operator tooling or a runbook keyed on one scheme misses or misidentifies archives made by the other; during incident response a superseded manifest is believed current, or vice versa.", + "confidence": "medium (inferred).", + "severity": "low", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-13", + "kind": "finding", + "title": "A forge failure after `Allowed` leaves the PR a draft with no retry", + "topics": "NIT-03.2", + "evidence": "\"a forge failure after `Allowed` is recorded in `automerge_error` and the pull request stays a draft (persist.rs:327); there is no retry.\"", + "failure": "a transient API error at `enable_automerge` leaves a change the client expected to merge unattended sitting as a draft indefinitely; nothing in this material alerts anyone.", + "confidence": "high (stated).", + "severity": "low", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-14", + "kind": "finding", + "title": "A duplicated descriptor in a forked child can make a free lock look held past the retry window", + "topics": "NIT-01.4", + "evidence": "the lock is per open file description (runstate.rs:274); `acquire_within` retries only \"for a short while because a descriptor briefly duplicated into a forked child can make a just released lock look held\" (runstate.rs:295); the wait budget elsewhere is 500 ms (runstate.rs:442).", + "failure": "a long-lived forked child inherits the descriptor; the next night's acquire exhausts its retry and fails with `Held`; what a host does with a `Held` error is unstated, so the night may simply be skipped.", + "confidence": "low (inferred).", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "nightly:premortem:F-15", + "kind": "finding", + "title": "The two-attempt budget drops gate-accepted nights and parks flapping nights", + "topics": "NIT-01.1, NIT-01.2", + "evidence": "the default budget is two, \"one clean try plus one recovery\" (runstate.rs:51); `Abandoned` is reachable from any non-terminal phase, including `Gated`, when the budget is spent (NIT-01.1); `begin`'s answer there is \"record and move on\" (runstate.rs:547).", + "failure": "two consecutive infra crashes unrelated to the change abandon a night whose candidate had already passed the gate; the accepted verdict is shelved with only a journal record, and delivery depends on an operator noticing `Abandoned`.", + "confidence": "medium (inferred — the mechanics are stated; the harm framing is mine).", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "nightly", + "high": false + }, + { + "id": "selection:critical:F-01", + "kind": "finding", + "title": "Secrets in failure text are persisted to a shared directory with only volatility normalisation, not redaction", + "topics": "SEL-02.1, SEL-02.4", + "evidence": "The crate claims only that \"long opaque identifiers such as tokens never reach a persisted cause\" (`crates/failure-fingerprint/src/lib.rs:15`). Normalisation replaces paths, long opaque ids and digit runs with placeholders (`crates/failure-fingerprint/src/lib.rs:177`, `crates/failure-fingerprint/src/lib.rs:190`); the persisted cause is exactly this normalised text (`crates/failure-fingerprint/src/lib.rs:410`), written as a per-repo file in a shared state directory (`crates/dream-core/src/streak.rs:94`). Nothing states that short or non-\"opaque-looking\" secrets (letter-only bearer tokens, usernames, internal hostnames, query strings) are removed; digit and path replacement are volatility rules, not redaction.", + "failure": "An evaluator fails with an error message embedding a short-lived credential or internal endpoint; normalisation leaves it inside the first 200 characters; the cause is written to the shared state directory and retained for as long as the failure persists (SEL-02.4), readable by anything with access to that directory, with no stated retention bound or access control.", + "confidence": "medium; inferred — the gap follows from what the \"long opaque\" qualifier leaves uncovered.", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:critical:F-02", + "kind": "finding", + "title": "Deterministic fingerprint collisions from normalisation and 200-char truncation bypass the new-failure veto", + "topics": "SEL-02.1, SEL-02.3", + "evidence": "The fingerprint is SHA-256 over task, stage and a cause normalised by replacing paths/ids/digit-runs with placeholders and truncating to 200 characters (`crates/failure-fingerprint/src/lib.rs:190`, `crates/failure-fingerprint/src/lib.rs:357`). Two genuinely different failures at the same task and stage whose normalised causes agree on the first 200 characters produce identical fingerprints, so the candidate's failure matches a baseline record and is classified `Persisting`, not `New` — the veto at `crates/dream-gate/src/pertest.rs:253`/`:261` never fires. The authors' Tension covers only 64-bit hash width (`crates/failure-fingerprint/src/lib.rs:263`); content collisions from truncation and digit/id erasure need no birthday luck.", + "failure": "Baseline fails with a message whose normalised form is e.g. `expected items after : `; a candidate introduces a different defect at the same task whose normalised cause matches within 200 chars; the new failure is classified `Persisting`, no veto is raised, and the change ships with a new failure class and no signal anywhere.", + "confidence": "medium — the truncation and placeholder rules are stated; the collision frequency is inferred.", + "severity": "high", + "marked_by_authors": "no (the marked Tension at SEL-02.1 covers only hash width)", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:critical:F-03", + "kind": "finding", + "title": "A failed parse of either run silently skips the fingerprint veto and NoRegression, not just statistics", + "topics": "SEL-01.3, SEL-02.3", + "evidence": "Per-test evidence exists only when the manifest declares a `test_format` **and** \"a parse of both runs succeeds; otherwise, the builder returns `None`\" (`crates/dream-core/src/testfmt.rs:543`); with `None`, the evaluator is \"judged by the receipt alone, no statistics\" (`crates/dream-gate/src/pertest.rs:187`). The fingerprint veto (`pertest.rs:253`/`:261`) and NoRegression sit inside the per-test branch, after the `None` branch in the flowchart — so an unparseable candidate output, not only an undeclared format, bypasses the new-failure check. Contrast `pertest.rs:190`, where invalid *options* do get a harness veto; malformed *results* get neither a veto nor a flag. The authors' Debt marker records only \"no statistics\".", + "failure": "A candidate truncates or subtly changes its test-output format so the candidate parse fails; the builder returns `None`; the change is judged on the receipt alone, introduces brand-new failures in that evaluator, and no evidence veto is raised — malformed output is treated as absence of evidence.", + "confidence": "medium; inferred from the flowchart branch structure — what \"receipt alone\" validates is unknown.", + "severity": "high", + "marked_by_authors": "partly — Debt at SEL-01.3 notes missing statistics, not the veto bypass", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:critical:F-04", + "kind": "finding", + "title": "Policy `Inconclusive` is dropped, breaking the \"say so when too many did not\" promise; the two fault-classification layers are not verified equivalent", + "topics": "SEL-01 (business), SEL-01.1, SEL-01.3", + "evidence": "The business text promises the rules \"say so when too many did not\" produce a real result, and the crate guard returns `Inconclusive` when the fault share exceeds 0.5 or fewer than 1 comparable exists (`crates/pairwise-gate/src/model.rs:283`, `crates/pairwise-gate/src/policy.rs:39`). But the engine acts only on `Reject`: \"policy `Inconclusive` … adds no veto at all\" (`crates/dream-gate/src/pertest.rs:304`), and the authors themselves state the per-test fault check (`pertest.rs:214`) and the crate-level fault notion \"are not obviously equivalent\".", + "failure": "A candidate run whose outcomes lack scores in a way the per-test layer does not classify as a fault drives the policy `Inconclusive`; per-test evidence contributes nothing; if nothing else vetoes, the night's decision is made by layers that never saw the evidence, and the promised \"say so\" never becomes a veto or a report.", + "confidence": "high for the drop-`Inconclusive` behaviour (stated); medium that the two fault layers diverge — that is the authors' own flagged uncertainty.", + "severity": "high", + "marked_by_authors": "yes (Tension, SEL-01.3)", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:critical:F-05", + "kind": "finding", + "title": "The ADR-mandated held-out (TrainDev) guard is never constructed by the nightly", + "topics": "SEL-01.2, SEL-01.3", + "evidence": "The gate's options have no held-out field and the policy builder \"never constructs `TrainDev`\" (`crates/dream-gate/src/pertest.rs:131`, `crates/dream-gate/src/pertest.rs:314`); ADR-DE-004 — the governing document for this area — states no consumer supplies held-out evaluators (`docs/adr/ADR-DE-004-pairwise-selection-and-fingerprints.md:55`). The train/dev composition the ADR specifies is unreachable in the only consumer shown.", + "failure": "A candidate that overfits or games the visible suite (leakage, suite-specific tuning) is selected every night, because the held-out rejection path cannot run; the regression ships carrying the gate's approval.", + "confidence": "high", + "severity": "high", + "marked_by_authors": "yes (Debt, SEL-01.3)", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:critical:F-06", + "kind": "finding", + "title": "A 64-bit fingerprint collision silently defeats both the gate veto and stall detection", + "topics": "SEL-02.1, SEL-02.4", + "evidence": "The identity is the first 8 bytes of SHA-256 (`crates/failure-fingerprint/src/lib.rs:263`); the authors state a collision between a new failure and a baseline failure \"would classify the new one as `Persisting` and let it past the gate's new-failure veto, with no signal\" (SEL-02.1), and the cross-night use keeps records for as long as the failure persists, \"which is where a long-lived collision would matter\" (SEL-02.4 Open).", + "failure": "Over months of retained streak manifests, one collision merges a new environment fault into an old record; the stuck-harness pattern still matches the old record and the new fault is never surfaced; ADR-DE-004 names exactly this as its review trigger.", + "confidence": "high — the behaviour is stated; the probability is the authors' claim.", + "severity": "medium", + "marked_by_authors": "yes (Tension SEL-02.1; related Open SEL-02.4)", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-07", + "kind": "finding", + "title": "Cross-night state is one unreplicated file that fails open to empty on corruption, with no stated atomicity or locking", + "topics": "SEL-02.4", + "evidence": "The streak state is \"per-repo manifest file in the shared state directory\" (`crates/dream-core/src/streak.rs:94`); \"corrupt or absent means an empty manifest\" (`streak.rs:124`) and \"a corrupt manifest fails open to an empty one\" (`streak.rs:261`). Nothing describes atomic write, backup, repair, or locking; nothing addresses two runs for the same repo writing the same file.", + "failure": "A crash or disk-full mid-write corrupts the manifest; the next night loads an empty manifest, every previously persisting harness fault becomes `New`, the \"some persisting, none new\" stuck pattern can no longer match, and stall detection stays disabled with no error raised. Alternatively, two overlapping pipelines for one repo race on the file and last-write-wins silently loses a night's classification.", + "confidence": "high for the fail-open reset (stated); medium and inferred for the torn-write and concurrency scenarios.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-08", + "kind": "finding", + "title": "A single fault on either side appears to block the entire night", + "topics": "SEL-01.3, SEL-02.4", + "evidence": "\"any fault, or a baseline test missing on candidate → harness veto, BLOCKED-ENV\" (`crates/dream-gate/src/pertest.rs:214`, `pertest.rs:232`), and a \"night ends BLOCKED-ENV with harness-class vetoes\" (`crates/dream-core/src/streak.rs:68`). One flaky outcome in one evaluator ends the night blocked rather than downgrading that evaluator.", + "failure": "One chronically flaky test or one intermittent testbed hiccup blocks nightly selection most nights; the engine produces no decisions, and the only escalation path (the stuck notice) additionally requires the same fingerprint to persist — see F-11 — so chronic intermittent flakiness yields neither decisions nor escalation.", + "confidence": "medium; inferred — night-wide scope is read from the `streak.rs:68` wording, not shown explicitly.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-09", + "kind": "finding", + "title": "Known-flaky evidence-class failures have no cross-night memory and repeatedly veto good candidates", + "topics": "SEL-01.3, SEL-02.3, SEL-02.4", + "evidence": "The gate's baseline manifest is built from the same night's baseline failures only (`crates/dream-gate/src/pertest.rs:253`); cross-night fingerprint memory exists only for harness-class vetoes — \"only harness vetoes become observations\" (`crates/dream-core/src/streak.rs:74`, `streak.rs:236`). An evidence-class failure that is flaky on baseline but absent on a given night classifies as `New` on the candidate (`pertest.rs:253`/`:261`) and also hits NoRegression's pass→fail.", + "failure": "A test failing intermittently on baseline vetoes every candidate that touches it, night after night — \"new\" fingerprint one night, NoRegression the next; good changes are rejected and the suite accumulates flaky tests that can never be excused, because only harness-class faults earn cross-night forgiveness.", + "confidence": "medium; inferred — both mechanisms are stated, the recurring false rejection follows from them.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-10", + "kind": "finding", + "title": "TrainDev's crate-level \"never a silent pass\" guarantee is void at the engine layer", + "topics": "SEL-01.2, SEL-01.3", + "evidence": "Crate claim: in `TrainDev` a thin or fault-heavy held-out set \"makes the whole decision `Inconclusive` and never a silent pass\" (`crates/pairwise-gate/src/policy.rs:394`). Engine fact: only `Reject` is acted on — `Inconclusive` \"adds no veto at all\" (`crates/dream-gate/src/pertest.rs:304`). The guarantee cannot survive composition with the engine.", + "failure": "When held-out evaluators are eventually supplied (the direction ADR-DE-004 states), a candidate whose train evidence selects but whose dev set is too thin or fault-heavy returns `Inconclusive`; per-test adds nothing; the decision falls through to other layers with no dev-set signal — precisely the silent pass the crate promises to prevent.", + "confidence": "high — both facts are stated and the contradiction is direct.", + "severity": "medium (latent until TrainDev is wired)", + "marked_by_authors": "no (the drop-`Inconclusive` mechanism is the marked Tension at SEL-01.3; its contradiction with `policy.rs:394` is unmarked)", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-11", + "kind": "finding", + "title": "The stuck-harness pattern cannot match intermittent or alternating faults", + "topics": "SEL-02.2, SEL-02.4", + "evidence": "The stuck notice requires exactly \"BLOCKED-ENV, some persisting, none new, none fixed\" (`crates/dream-core/src/streak.rs:76`); a failure that disappears and returns is `New` again with its count reset (`crates/failure-fingerprint/src/lib.rs:782`), and `Fixed` records are dropped from the next manifest (SEL-02.2 diagram).", + "failure": "A testbed with two alternating faults, or any environment failure with an occasional clean night in between, never satisfies \"none new, none fixed\"; a genuinely broken testbed is never escalated, compounding F-08's blocked nights.", + "confidence": "medium; inferred — the state machine is stated, the escalation gap follows from it.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-12", + "kind": "finding", + "title": "The crate-level guard permits Select on one comparable pairing with half the data faulted", + "topics": "SEL-01.1, SEL-01.2", + "evidence": "The guard inconclusive only when the fault share is strictly above 0.5 or fewer than 1 comparable exists (`crates/pairwise-gate/src/model.rs:283`, `crates/pairwise-gate/src/policy.rs:39`); a share exactly at the limit is tolerated (`model.rs:226`), and `Margin` selects whenever wins minus losses exceeds the margin (`crates/pairwise-gate/src/policy.rs:98`) — so Select on a single comparison with 50% faults is a legal crate outcome. The engine's `min_cases` (`pertest.rs:279`, default 20 at `pertest.rs:155`) currently masks this for the statistical policies, but the weak bound lives inside the reusable crate.", + "failure": "A future consumer of `pairwise-gate` that does not replicate the engine's `min_cases` check selects a candidate on one data point, or on a run where half the pairings faulted.", + "confidence": "high on the stated bounds; the misuse scenario is inferred.", + "severity": "low", + "marked_by_authors": "no (presented as deliberate under \"Why it is this way\")", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-13", + "kind": "finding", + "title": "Candidate-induced hangs classified as faults would be misattributed to the harness", + "topics": "SEL-01.3, SEL-02.4", + "evidence": "A fault on either side is excluded from the tally as an infrastructure-style outcome (`crates/pairwise-gate/src/lib.rs:9`, `model.rs:205`) and becomes a BLOCKED-ENV harness veto (`pertest.rs:214`/`:232`); harness vetoes are the only cross-night observations (`streak.rs:74`) and drive the stuck notice (`streak.rs:136`).", + "failure": "Candidates whose tests hang or time out in ways classified as faults produce BLOCKED-ENV nights whose fingerprints persist; the harness-stuck notice fires and the team debugs the testbed while the trigger is the candidate stream.", + "confidence": "low; inferred — depends on whether timeouts and hangs land in the fault class, which the material does not define.", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-14", + "kind": "finding", + "title": "`count` is not a consecutive-streak count and correctness depends on caller discipline", + "topics": "SEL-02.2", + "evidence": "\"`count` is the previous count plus one and not a strict consecutive count; callers who need strictness check `prev.step + 1 == step`\" (`crates/failure-fingerprint/src/lib.rs:539`); a persisting record just increments (`lib.rs:576`).", + "failure": "A night that fails to run, or crashes before the manifest write, is invisible to the count; downstream readers treating `count` as streak length overstate persistence, and any caller that forgets the adjacency check misreads a gap-separated recurrence as consecutive.", + "confidence": "high", + "severity": "low", + "marked_by_authors": "yes (Debt, SEL-02.2)", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:critical:F-15", + "kind": "finding", + "title": "The 20-case statistical floor is a config field, not an enforced invariant", + "topics": "SEL-01.2", + "evidence": "ADR-DE-004 and the code default both use 20 as the floor for the sign test and the Wilson bound, \"and it is a field on the options\" (SEL-01.2 Open; `crates/dream-gate/src/pertest.rs:155`).", + "failure": "An operator sets `min_cases` low, or a future caller omits it; exact one-sided sign tests and Wilson bounds run on a handful of pairings and `Select` on noise; nothing in the crate enforces the ADR's floor.", + "confidence": "high on configurability; the misconfiguration scenario is inferred.", + "severity": "low", + "marked_by_authors": "yes (Open, SEL-01.2)", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:R-1", + "kind": "root-cause", + "title": "A fault-heavy candidate accumulates zero evidence vetoes, because policy `Inconclusive` is dropped and the one remaining guard is not proven equivalent to the crate's fault handling", + "topics": "SEL-01.1, SEL-01.3", + "evidence": "fault-not-loss `crates/pairwise-gate/src/lib.rs:9`; guard `crates/pairwise-gate/src/model.rs:283`, `crates/pairwise-gate/src/policy.rs:39`; only `Reject` acted on and the equivalence doubt, both in the SEL-01.3 Tension note at `crates/dream-gate/src/pertest.rs:304`; harness veto conditions `crates/dream-gate/src/pertest.rs:214`, `:232`; additivity `crates/dream-gate/src/pertest.rs:12`.", + "failure": null, + "confidence": "high that steps 1–4 are the stated design; medium, inferred, for step 5's concrete mismatch and for the promotion semantics in step 6.", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:premortem:R-2", + "kind": "root-cause", + "title": "A genuinely new failure inherits an unrelated baseline failure's fingerprint and is classified `Persisting`, so the new-failure veto stays silent and the candidate is promoted", + "topics": "SEL-02.1, SEL-02.2, SEL-02.3, SEL-01.3", + "evidence": "the authors' own words — a collision \"would classify the new one as `Persisting` and let it past the gate's new-failure veto, with no signal\", and ADR-DE-004 lists a collision seen in practice as its review trigger (`crates/failure-fingerprint/src/lib.rs:263` note); normalisation rules `crates/failure-fingerprint/src/lib.rs:190`; hashing `:357`, `:365`; Persisting semantics `:576`; veto mapping `crates/dream-gate/src/pertest.rs:255`, `:261`.", + "failure": null, + "confidence": "high that any fingerprint match produces the silent bypass (stated); medium, inferred, that placeholder-merging or truncation produces such matches in this suite; low for a raw 64-bit collision as the trigger.", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:premortem:R-3", + "kind": "root-cause", + "title": "Credential material in a failure cause survives the normalisation heuristics and is persisted indefinitely in a per-repo manifest in the shared state directory", + "topics": "SEL-02.1, SEL-02.2, SEL-02.4", + "evidence": "the material claims only that \"long opaque identifiers such as tokens never reach a persisted cause\" (`crates/failure-fingerprint/src/lib.rs:15`) — a heuristic scope, not a guarantee over secrets generally; the persisted-text invariant `crates/failure-fingerprint/src/lib.rs:410`; the shared-directory manifest `crates/dream-core/src/streak.rs:94`; long retention noted in SEL-02.4 Open.", + "failure": null, + "confidence": "low, inferred — no leak is shown; the defect is the gap between the stated heuristic and secrets in general.", + "severity": "high", + "marked_by_authors": null, + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:premortem:F-01", + "kind": "finding", + "title": "`TrainDev`'s \"never a silent pass\" guarantee is defeated one layer up, because the gate acts only on `Reject`", + "topics": "SEL-01.2, SEL-01.3", + "evidence": "a thin or fault-heavy held-out set \"makes the whole decision `Inconclusive` and never a silent pass\" (`crates/pairwise-gate/src/policy.rs:394`), but `pertest` maps only `Reject` to a veto and `Inconclusive` \"adds no veto at all\" (`crates/dream-gate/src/pertest.rs:304`).", + "failure": "even after the wiring debt in F-02 is paid, a thin dev evaluator still produces no gate veto; the silent pass recurs at the gate layer.", + "confidence": "high — both statements are cited.", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:premortem:F-02", + "kind": "finding", + "title": "`TrainDev` and held-out evaluators are never wired into the nightly", + "topics": "SEL-01.2", + "evidence": "the gate's options have no held-out field and the policy builder never constructs `TrainDev` (`crates/dream-gate/src/pertest.rs:131`, `:314`); ADR-DE-004 states no consumer supplies held-out evaluators yet (`docs/adr/ADR-DE-004-pairwise-selection-and-fingerprints.md:55`).", + "failure": "a candidate overfit to the visible suite passes the train-only gate and is selected night after night.", + "confidence": "high — the gap is stated.", + "severity": "high", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:premortem:F-03", + "kind": "finding", + "title": "Evaluators without a `test_format` get no per-test evidence at all", + "topics": "SEL-01.3", + "evidence": "the builder returns `None`, never fabricated evidence (`crates/dream-core/src/testfmt.rs:543`); such evaluators are \"judged by the receipt alone, no statistics\" (`crates/dream-gate/src/pertest.rs:187`).", + "failure": "a regression confined to such an evaluator is invisible to both the fingerprint veto and every policy veto; the change is promoted on the receipt alone.", + "confidence": "high — stated.", + "severity": "high", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:premortem:F-04", + "kind": "finding", + "title": "Below `min_cases` the evaluator is one case, and `Floor` is candidate-only", + "topics": "SEL-01.2, SEL-01.3", + "evidence": "below `min_cases` the evaluator counts as one case, judged by `NoRegression` and `Floor` only (`crates/dream-gate/src/pertest.rs:282`); `Floor` checks only candidate scores against the floor (`crates/pairwise-gate/src/policy.rs:156`).", + "failure": "in a small evaluator, a candidate that degrades every score but stays above the floor and triggers no new fingerprint adds no veto.", + "confidence": "high for the behaviour; medium for the harm.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-05", + "kind": "finding", + "title": "A fault share of exactly 0.5 is tolerated: the rule runs on half-missing evidence", + "topics": "SEL-01.1", + "evidence": "only a share larger than the limit is inconclusive; exactly at the limit is tolerated (`crates/pairwise-gate/src/model.rs:226`).", + "failure": "an evaluator judged with half its pairings faulted proceeds to the rule on the remainder, treating a half-evacuated evidence base as sufficient.", + "confidence": "high — stated as deliberate.", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-06", + "kind": "finding", + "title": "A corrupt cross-night manifest fails open and silently disables stuck-harness detection", + "topics": "SEL-02.4", + "evidence": "a corrupt or absent manifest loads as empty (`crates/dream-core/src/streak.rs:124`, `:261`); from an empty manifest every current harness fault is `New`, and the stuck notice requires \"some persisting, none new\" (`crates/dream-core/src/streak.rs:76`, `:136`).", + "failure": "a partial write or corruption erases history; a genuinely stuck testbed is reclassified as all-new every night and never escalated.", + "confidence": "high — both halves stated.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-07", + "kind": "finding", + "title": "A flapping fault is `New` forever, so stuck detection cannot fire for intermittent harness faults", + "topics": "SEL-02.2, SEL-02.4", + "evidence": "`Fixed` → `New` on return with the count reset (`crates/failure-fingerprint/src/lib.rs:782`); the stuck notice requires some persisting records (`crates/dream-core/src/streak.rs:76`).", + "failure": "a harness fault that recurs every other night is `New` on each return, never persisting, and is never escalated as stuck.", + "confidence": "high for the mechanism; medium that it is a defect rather than intent.", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-08", + "kind": "finding", + "title": "`count` is cumulative, not consecutive; consumers must re-derive strictness themselves", + "topics": "SEL-02.2", + "evidence": "`count` is the previous count plus one; callers needing strictness must check `prev.step + 1 == step` (`crates/failure-fingerprint/src/lib.rs:539`).", + "failure": "a consumer reading `count` as consecutive nights overestimates streaks across gaps and mis-escalates.", + "confidence": "high — stated debt.", + "severity": "low", + "marked_by_authors": "yes (Debt)", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-09", + "kind": "finding", + "title": "Cross-night records are long-lived, which is where a 64-bit collision would matter", + "topics": "SEL-02.1, SEL-02.4", + "evidence": "SEL-02.4 Open — the gate use needs only two trees, while the cross-night use \"keeps records for as long as the failure persists\"; the identity is 64 bits (`crates/failure-fingerprint/src/lib.rs:263`).", + "failure": "two distinct harness faults sharing a fingerprint merge into one long-lived record; escalation and stuck classification are wrong indefinitely.", + "confidence": "medium — the exposure is flagged by the authors; the occurrence is inferred.", + "severity": "low", + "marked_by_authors": "yes (Open)", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-10", + "kind": "finding", + "title": "Suspected: crate-level `NoRegression` does not treat baseline-pass → candidate-fault as a regression", + "topics": "SEL-01.1, SEL-01.3", + "evidence": "a fault pairing \"is never a win or a loss\" (`crates/pairwise-gate/src/model.rs:205`); `NoRegression` is described only as pass→fail (`crates/pairwise-gate/src/policy.rs:364`); the SEL-01.3 Tension says faults \"are caught separately by the harness veto at step 1\", implying the policy layer does not handle them; nothing in the material states that pass→fault rejects.", + "failure": "a candidate-side fault on a previously passing test escapes the crate policy and rests entirely on the per-test \"any fault\" check — the same check whose equivalence the authors doubt (see R-1).", + "confidence": "medium, inferred — it is an absence of statement, not a statement.", + "severity": "high", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": true + }, + { + "id": "selection:premortem:F-11", + "kind": "finding", + "title": "In the gate, the improvement policies can only reject; `Select` is inert, so the per-test layer appears to require the absence of vetoes rather than positive evidence", + "topics": "SEL-01.2, SEL-01.3", + "evidence": "only `Reject` is acted on (`crates/dream-gate/src/pertest.rs:304`); per-test evidence \"can only add vetoes\" (`crates/dream-gate/src/pertest.rs:12`); `Margin`, `SignTest` and `WilsonLower` are in the gate's stack (`crates/dream-gate/src/pertest.rs:326`) but their `Select` outcome has no stated effect there.", + "failure": "a candidate that improves nothing, triggers no new fingerprint and no pass→fail transition accumulates no veto and passes — whether any other layer requires positive evidence is not stated in the material.", + "confidence": "medium, inferred — the per-test behaviour is stated; the promotion semantics are not.", + "severity": "medium", + "marked_by_authors": "yes (Tension — \"only `Reject` is acted on\")", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-12", + "kind": "finding", + "title": "Task identity is part of the fingerprint, so task renaming makes known failures unmatchable", + "topics": "SEL-02.1, SEL-02.3", + "evidence": "the identity hashes task, stage and cause (`crates/failure-fingerprint/src/lib.rs:357`); the gate rebuilds the baseline manifest from the baseline run each night (SEL-02.3, SEL-02.4 Open).", + "failure": "the suite reindexes task names between the baseline and candidate runs; the candidate's known failures no longer match the baseline's, are all classed `New`, and good changes are falsely rejected — a chronic fail-closed condition.", + "confidence": "medium, inferred — the mechanism is stated; the occurrence is not.", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-13", + "kind": "finding", + "title": "Any single fault anywhere raises a harness veto for the whole night", + "topics": "SEL-01.3", + "evidence": "\"any fault, or a baseline test missing on candidate\" → harness veto, BLOCKED-ENV (`crates/dream-gate/src/pertest.rs:214`, `:232`).", + "failure": "nightly promotions stall on any infra flake; the stated consequence is only blocking, but chronic blocking is the classic pressure that produces unvetted manual overrides — the override, not the block, would be the incident.", + "confidence": "low, inferred — the blocking is stated; the override behaviour is not.", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-14", + "kind": "finding", + "title": "Truncation at 200 characters merges any long causes sharing a prefix", + "topics": "SEL-02.1", + "evidence": "whitespace is collapsed and the cause truncated to 200 characters before hashing (`crates/failure-fingerprint/src/lib.rs:190`).", + "failure": "two long, distinct causes (long stack traces with a shared header) are identical after truncation, share a fingerprint, and the second is hidden as `Persisting` — the same silent bypass as R-2 by a different route.", + "confidence": "medium, inferred — the rule is stated; the merging follows from it.", + "severity": "medium", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + }, + { + "id": "selection:premortem:F-15", + "kind": "finding", + "title": "Within one step, observations with the same fingerprint are de-duplicated, first wins", + "topics": "SEL-02.2", + "evidence": "same-fingerprint observations in one step are de-duplicated, first wins (`crates/failure-fingerprint/src/lib.rs:562`).", + "failure": "several genuinely distinct failures that normalise identically in one run collapse into one record; the count under-reports and, in the gate's step 2, the second failure is invisible if the first matched the baseline.", + "confidence": "medium, inferred.", + "severity": "low", + "marked_by_authors": "no", + "status": "unverified", + "shard": "selection", + "high": false + } +] diff --git a/docs/review/2026-10-17-glm.md b/docs/review/2026-10-17-glm.md new file mode 100644 index 0000000..322e33d --- /dev/null +++ b/docs/review/2026-10-17-glm.md @@ -0,0 +1,1135 @@ +# GLM review 2026-10-17 (glm-5.3): 128 findings, 49 high severity, 4 shard(s) + +Revision 9503e81f149c. Shards: crates, gate, nightly, selection. Unverified hypotheses: reproduce each with a failing test or check (build-with-quality) before acting. + +## crates / critical + +### F-01 — Model-generated code is built and executed before any human sees it, and no isolation mechanism appears anywhere +- Topics: CRT-02.2, CRT-02.1 +- Evidence: The documented night has the host "build the candidate and re-run (candidate.rs:296 and 300)" evaluators against the LLM's "report with a patch block", and the shipped `LocalRunner` executes evaluators locally (`crates/dream-core/src/runner.rs:48`). No sandbox, container, or privilege boundary is mentioned for this step in either file. +- Failure: a prompt-injected or misbehaving model emits a patch whose build script or test code exfiltrates credentials or modifies the host; it runs with the night process's privileges, hours before review. +- Confidence: medium, inferred — execution of model output is stated; the absence of isolation is an absence in the material, not an asserted fact. +- Severity: high +- Marked by authors: no + +### F-02 — The engine enables automerge on LLM-authored PRs; no human-approval step appears anywhere +- Topics: CRT-02.1, CRT-02.2 +- Evidence: `Forge::enable_automerge` is a first-class seam operation, used at "publish_draft and optional automerge" (`crates/dream-core/src/persist.rs:298`). No approval or review gate appears in the trait list, the sequence, or either business paragraph. The live-path testing gap is separately acknowledged: "push, PR creation and branch-protection paths have not been exercised against a live GitHub" (`crates/dream-core/src/seams.rs:647`). +- Failure: overnight, a plausible-but-wrong patch passes its evaluators and merges into the base branch with no human in the loop; the branch-protection call meant to gate it is exactly the path never tested. +- Confidence: medium, inferred — `enable_automerge` is stated; "no approval required" is implied by its place in an autonomous night, not asserted. +- Severity: high +- Marked by authors: partially — Debt covers the untested forge path; the missing approval gate is unmarked. + +### F-03 — Publishing runs on ambient `gh`/`git` credentials whose scope is nowhere constrained +- Topics: CRT-02.1 +- Evidence: `GhForge` works via "git push and gh CLI" (`crates/dream-core/src/seams.rs:424`), i.e. whatever credential the environment supplies; no token scoping or least-privilege requirement is stated for the identity that pushes drafts and mutates branch protection. +- Failure: the night process holds a broad GitHub token (typically an operator's personal credential); any code executing during the night (F-01) or any host misconfiguration acts with full push and branch-protection rights on the repository. +- Confidence: medium, inferred — the credential mechanism is stated; the absence of scoping is absence in the material. +- Severity: high +- Marked by authors: no + +### F-04 — Automerge is ordered before the best-effort memory write: a merged change can leave no durable record +- Topics: CRT-02.2 +- Evidence: the stated sequence is publish_draft and "optional automerge" (`crates/dream-core/src/persist.rs:298`) and only then "record the night, best effort" (`crates/dream-core/src/seams.rs:147`: callers "log and continue"). Nothing between the two steps is durable. +- Failure: crash, restart, or disk-full in the window after automerge succeeds: a change is merged into the base branch with no memory record — the audit trail of what the autonomous system did to production is gone. +- Confidence: high — the ordering and best-effort semantics are both stated; the window follows directly. +- Severity: high +- Marked by authors: no + +### F-05 — Engine-side records sit outside the product's encryption envelope +- Topics: CRT-01.2, CRT-02.1 +- Evidence: the "private encryption envelope" exists only on the product side and is "never published" (`docs/adr/ADR-DE-002-licence-and-provenance.md:30`), while the engine's durable default is a local JSONL file (`JsonlSink`, `crates/dream-core/src/seams.rs:202`). +- Failure: default-configured or trial nights persist prompts, patches and evaluator output as plaintext on the runner's disk; the confidentiality the product claims covers only records that never pass through the engine's sinks. +- Confidence: medium, inferred — the split is stated; the sensitivity of engine-side record contents is not. +- Severity: medium +- Marked by authors: no + +### F-06 — Forge failures are never raised; the night records a verdict regardless of what happened to the PR +- Topics: CRT-02.2, CRT-02.1 +- Evidence: "A forge failure is reported inside the `PrOutcome`, never raised, so the night still records its verdict" (`crates/dream-core/src/seams.rs:365`); `GhForge` does push and PR creation as separate ambient operations (`seams.rs:424`), which admits partial publishes. +- Failure: branch pushed but PR creation fails (expired token, rate limit): the night concludes and records its verdict; the remote carries an orphan branch diverging from the recorded outcome; nothing retries or alerts, because notification is infallible (F-08). +- Confidence: high +- Severity: medium +- Marked by authors: no — the Debt covers GhForge test coverage, not this failure-containment policy. + +### F-07 — Memory is best-effort by design: IO failure silently deletes the audit trail +- Topics: CRT-02.2, CRT-02.1 +- Evidence: "Memory writes are best effort and callers log and continue" (`crates/dream-core/src/seams.rs:147`); the shipped sink is a local JSONL file (`seams.rs:202`). +- Failure: disk full or sink path unwritable for an entire night: the night keeps publishing and merging; in the morning only stderr logs (if retained) hold any record, and the loss itself is signalled nowhere. +- Confidence: high +- Severity: medium +- Marked by authors: no + +### F-08 — `Notifier::notify` is infallible by signature: notification loss is structurally unreportable +- Topics: CRT-02.1 +- Evidence: stated as an "Invariant": "notify is infallible by signature, so a broken channel cannot change a verdict" (`crates/dream-core/src/seams.rs:320`). A signature with no error return cannot express delivery failure. +- Failure: the chat channel is down all night; every notify silently no-ops; nobody learns the autonomous system ran, published, or merged until the damage is found by other means. +- Confidence: high — the signature fact is stated; silent drop is its direct implication. +- Severity: medium +- Marked by authors: no — it is presented as an invariant, the opposite of a flag. + +### F-09 — No seam call carries a timeout or cancellation; one hung call stalls the night, and the lock likely stalls the ones after it +- Topics: CRT-02.1, CRT-02.2 +- Evidence: the documented trait surface — `Llm::complete`, `EvaluatorRunner::run`, `Forge::publish_draft`, etc. — has no deadline or cancellation parameter (`crates/dream-core/src/seams.rs`, `crates/dream-core/src/runner.rs` method lists), and "the async ones return a plain boxed future so the crate links no async runtime" (`seams.rs:39`): any timeout must come from the host. Nights are serialized by `NightLock` (`Cargo.toml:7`). +- Failure: the model endpoint accepts the connection and never answers at 01:00; the night hangs holding the night lock; subsequent scheduled nights queue or fail; morning arrives with zero work done and no signal raised. +- Confidence: medium, inferred — the signatures shown lack timeouts; host-side deadline behaviour is not stated. +- Severity: medium +- Marked by authors: no + +### F-10 — `ScriptedRunner` ships beside the real runner with no stated production guardrail +- Topics: CRT-02 (business), CRT-02.1 +- Evidence: both `LocalRunner` (`crates/dream-core/src/runner.rs:48`) and `ScriptedRunner` (`runner.rs:137`) implement `EvaluatorRunner`; the business text endorses "a full night on a laptop with scripted test results"; no mechanism is described that keeps scripted results away from the gate in a production deployment. The gate is, by design, "a pure function" over whatever results it is handed (`crates/dream-gate/src/gate.rs:8`). +- Failure: a misconfigured or shortcut-taking host uses `ScriptedRunner` in production; the gate judges fabricated greens and F-02's automerge merges the patch. +- Confidence: medium, inferred — the coexistence and pure gate are stated; the guardrail's absence is absence in the material. +- Severity: medium +- Marked by authors: no + +### F-11 — The documented host order is enforced by nothing, and the dangerous mis-orderings are exactly the unguarded ones +- Topics: CRT-02.2 +- Evidence: "No driver in this repository enforces it, so the order is not machine-checked here" — the order exists only in crate docs (`crates/dream-core/src/lib.rs:3`). +- Failure: an adapter calls `publish_draft`/`enable_automerge` before re-running the evaluators on the candidate; the seams will happily execute in that order, and an unevaluated patch can reach merge. +- Confidence: high +- Severity: medium +- Marked by authors: yes (Open) + +### F-12 — "Defaults that need nothing external / a full night on a laptop" contradicts "Llm: no implementation ships" +- Topics: CRT-02 (business), CRT-02.1 +- Evidence: business text: "The engine ships plain defaults that need nothing external, so a team can try a full night on a laptop"; CRT-02.1: "no implementation ships" for `Llm` (`crates/dream-core/src/seams.rs:96`), while the documented night requires an `Llm` call that produces the patch. The repo also ships `dream-cli`, yet CRT-02 says no driver in this repository drives the sequence. +- Failure: a team plans pre-production validation around the promised no-services dry run; it cannot exist as described, so real model and forge integration is deferred and first exercised directly in production. +- Confidence: high — both statements are in the material. +- Severity: medium +- Marked by authors: no + +### F-13 — ADR-DE-002 still says four core crates; the stamped "Resolved" note fixed the README, not the contradiction +- Topics: CRT-01.1, CRT-01.2 +- Evidence: "ADR-DE-002 speaks of four core crates (`docs/adr/ADR-DE-002-licence-and-provenance.md:23`), while the workspace has five members (`Cargo.toml:3`)"; the note claims resolution because the README now describes the built gate (`README.md:57`) — the ADR text itself is untouched and still wrong. +- Failure: the governing licence/provenance record miscounts the published surface; anyone deriving obligations (which crates are published under MIT OR Apache-2.0) from the ADR gets the wrong set. +- Confidence: high +- Severity: low +- Marked by authors: yes (Open) — but the "Resolved since the earlier stamp" claim, which is itself false, is unmarked. + +### F-14 — The declared MSRV 1.89 is load-bearing (`NightLock`) and never compiled by any CI job +- Topics: CRT-01.2 +- Evidence: "the workspace declares a minimum Rust version of 1.89 (`Cargo.toml:7`) because `NightLock` uses `File::try_lock`, but every CI job installs `stable` (`.github/workflows/ci.yml:26`); nothing builds on 1.89". +- Failure: a downstream consumer builds on the declared floor and fails to compile on an API added after 1.89 — a publishing incident invisible to this repository's CI. +- Confidence: high +- Severity: low +- Marked by authors: yes (Debt) + +### F-15 — "The fence is physical" overstates the licence boundary: the reverse direction is convention plus a ban list that cannot see the product tree +- Topics: CRT-01.2 +- Evidence: business text: "The fence is physical: that code is not in this repository, so it cannot be pulled in by accident"; the authors' own Open: "nothing in this repository verifies that the product's adapter follows the rule in the other direction"; `deny.toml`'s bans (`deny.toml:26`) inspect only this repository's dependency graph. +- Failure: over time the product's copyleft adapter absorbs engine behaviour or vendored engine code on its side; no check in this repo can detect it, and the compliance posture asserted in the business text is permanently unverified. +- Confidence: high +- Severity: low +- Marked by authors: yes (Open) + +## Not judgeable from this material + +1. **Isolation of candidate builds/runs** — the runner implementation and deployment config. Gates F-01/F-03: whether a sandbox exists and what privileges the night process holds. +2. **Automerge conditions** — what `required_status_checks` returns, whether any human approval is required before `enable_automerge` takes effect, and what the pairwise gate's verdict feeds into the merge decision. Gates F-02/F-10. +3. **Memory sink contents and durability** — what `JsonlSink` records actually contain (secrets, source, prompts), and its behaviour under concurrent writers or rotation. Gates F-05/F-07. +4. **`NightLock` semantics** — lock-file scope, behaviour when `try_lock` fails (second instance aborts or runs unlocked?), and whether the lock spans awaits. Gates F-09 and any multi-instance/multi-repo deployment. +5. **`dream-cli`'s actual role** — whether it drives any part of a night, given CRT-02 says no driver in the repository drives the documented order and no `Llm` implementation ships. Gates F-11/F-12. + +## crates / premortem + +# Post-mortem: the night that lost the client + +The engine described in CRT-01/CRT-02 was run by a host adapter that this repository does not contain and does not constrain. Everything below is reconstructed from the design facts and author-flagged gaps in the distillation; every citation is checkable against the named file and line. Where I reason past what the material states, I say so. + +--- + +### R-1 — The night recorded a verdict for a change that was never published (or merged without protection) +- Topics: CRT-02.1, CRT-02.2 +- Chain of events: + 1. The host driver finishes evaluation and calls `publish_draft` and optional automerge through the engine's persist path (`crates/dream-core/src/persist.rs:298`). + 2. The `GhForge` push, PR-creation or branch-protection call fails — an expired token, a changed GitHub API, a protection rule that was never actually applied. These paths have never been exercised against a live GitHub at all. + 3. The failure is returned *inside* the `PrOutcome`, never raised, and the night proceeds to record its verdict anyway. + 4. The memory write is best effort ("log and continue") and notification is infallible by signature, so the failure surfaces nowhere durable — at most a stderr line. + 5. The client's repository never receives the fix, or receives it automerged without the intended status checks. The operator only learns of this from the client. +- Evidence: "A forge failure is reported inside the `PrOutcome`, never raised, so the night still records its verdict" (`crates/dream-core/src/seams.rs:365`). "`GhForge` has one test in this tree and it only proves the trait is dyn-safe… The push, PR creation and branch-protection paths have not been exercised against a live GitHub" (`crates/dream-core/src/seams.rs:647`, author-flagged Debt). Best-effort memory (`crates/dream-core/src/seams.rs:147`); infallible notify (`crates/dream-core/src/seams.rs:320`). +- Warning signs visible today: the Debt flag on the only forge test; the explicit "never raised" swallowing semantics; two consecutive reporting channels (memory, notifier) that are each incapable of escalating. +- Confidence: medium, inferred +- Severity: high + +### R-2 — The audit trail of what the engine did to the client's code does not survive +- Topics: CRT-02.1, CRT-02.2 +- Chain of events: + 1. A night runs with the shipped `JsonlSink` (or an unwired host falls back to `NullSink`). + 2. The write fails — disk full, permissions, path gone. Callers "log and continue"; the night's record of prompts, patches and verdicts is simply absent. + 3. The notifier cannot report the loss: its signature is infallible, so an implementation has no way to signal failure through the API. + 4. When the client disputes what was done, the operator cannot produce the record. The gate's advertised replayability is useless, because replay needs the recorded inputs that were never durably stored. +- Evidence: "Memory writes are best effort and callers log and continue" (`crates/dream-core/src/seams.rs:147`); `NullSink` and `JsonlSink` are the shipped sinks (`crates/dream-core/src/seams.rs:157`, `crates/dream-core/src/seams.rs:202`); "`Notifier::notify` is infallible by signature, so a broken channel cannot change a verdict" (`crates/dream-core/src/seams.rs:320`); the gate "can stay a pure function that a replay can re-run" (`crates/dream-gate/src/gate.rs:8`) — a promise with no matching durability promise for its inputs. +- Warning signs visible today: two of the three reporting seams are explicitly designed to be lossy; the replay promise in CRT-01.1 has no counterpart durability requirement anywhere in the material. +- Confidence: medium, inferred +- Severity: high + +### R-3 — A model-generated patch was automerged into the client's repository without real evaluation +- Topics: CRT-02.1, CRT-02.2 +- Chain of events: + 1. `Llm` is the one seam with no shipped implementation, so the host wires a real model; everything else can remain the shipped defaults, which "need nothing external". + 2. The evaluator seam is still wired to `ScriptedRunner`, whose results are scripted — or the adapter's driver order is wrong. Nothing in this repository checks either: no driver exists here and the intended order is docs-only. + 3. The candidate is built and "re-run" against canned passing results, then published with `enable_automerge`. The branch-protection path that would have made automerge safe is the same untested `GhForge` code. + 4. A hallucinated or prompt-injected patch block from the model's report enters the client's codebase merged, with no real test having gated it. +- Evidence: "no implementation ships" for `Llm` (`crates/dream-core/src/seams.rs:96`); `ScriptedRunner` at `crates/dream-core/src/runner.rs:137` and the business text "a full night on a laptop with scripted test results" (CRT-02); "No driver in this repository enforces [the order], so the order is not machine-checked here" (`crates/dream-core/src/lib.rs:3`, author-flagged Open); untested branch-protection path (`crates/dream-core/src/seams.rs:647`); the sequence applies a "report with a patch block" then re-runs evaluators (`crates/dream-core/src/candidate.rs:296`, `:300`). +- Warning signs visible today: an Open flag saying the order is unenforced; a Debt flag saying the protection path is unexercised; a default evaluator that cannot fail. +- Confidence: low, inferred +- Severity: high + +--- + +### F-01 — The declared minimum Rust version has never been built by anyone +- Topics: CRT-01.2 +- Evidence: The workspace declares `rust-version` 1.89 "because `NightLock` uses `File::try_lock`" (`Cargo.toml:7`), but "every CI job installs `stable`" (`.github/workflows/ci.yml:26`); author-flagged Debt states "nothing builds on 1.89, so the declared floor is untested." +- Failure: A deployment pinned to the declared floor fails to compile `NightLock`'s lock path in production, or the floor is simply wrong and a 1.89 toolchain in the field diverges from what CI ever validated. +- Confidence: high +- Severity: medium +- Marked by authors: yes (Debt) + +### F-02 — The licence boundary is unenforced on the adapter side +- Topics: CRT-01.2 +- Evidence: "nothing in this repository verifies that the product's adapter follows the rule in the other direction; the boundary is a convention plus a dependency ban in the permissive tree" (author-flagged Open, CRT-01.2); the one-direction rule is only documented (`docs/adr/ADR-DE-002-licence-and-provenance.md:34`). +- Failure: The adapter pulls a copyleft or host-tying crate into the deployed product; the client's legal review finds it; the operator must rip it out under deadline. +- Confidence: high for the gap, inferred for the consequence +- Severity: medium +- Marked by authors: yes (Open) + +### F-03 — The ADR's crate count contradicts the workspace +- Topics: CRT-01.1 +- Evidence: ADR-DE-002 "speaks of four core crates" (`docs/adr/ADR-DE-002-licence-and-provenance.md:23`) while the workspace has five members (`Cargo.toml:3`) — author-flagged Open. +- Failure: A reviewer or auditor working from the ADR misses `dream-cli` entirely, including whatever it wires together. +- Confidence: high +- Severity: low +- Marked by authors: yes (Open) + +### F-04 — The only forge implementation's real behaviour is untested +- Topics: CRT-02.1 +- Evidence: `GhForge` at `crates/dream-core/src/seams.rs:424`; its single test "only proves the trait is dyn-safe" (`crates/dream-core/src/seams.rs:647`, Debt). +- Failure: Push, PR creation and branch protection misbehave on first contact with a live GitHub — wrong base branch from `pr_base_branch`, automerge silently not enabled, checks not required. +- Confidence: high +- Severity: high +- Marked by authors: yes (Debt) + +### F-05 — The notifier cannot signal that notification is broken +- Topics: CRT-02.1 +- Evidence: "`Notifier::notify` is infallible by signature, so a broken channel cannot change a verdict" (`crates/dream-core/src/seams.rs:320`), presented as an invariant, not flagged. +- Failure: During an incident the channel is dead; the type system made that unreportable; the on-call human is never alerted and the failure runs all night. +- Confidence: high for the mechanism, inferred for the scenario +- Severity: medium +- Marked by authors: no + +### F-06 — An unwired host can run nights with zero memory +- Topics: CRT-02.1 +- Evidence: `NullSink` ships as a sink implementation (`crates/dream-core/src/seams.rs:157`) and the engine "ships plain defaults that need nothing external" (CRT-02 business text); nothing in the material states a check that a real sink was configured. +- Failure: An adapter constructs the engine without overriding the sink; entire nights of decisions leave no record at all. +- Confidence: inferred +- Severity: high +- Marked by authors: no + +### F-07 — The model's patch block is trusted input with no stated validation seam +- Topics: CRT-02.1, CRT-02.2 +- Evidence: `Llm` returns "a report with a patch block" which is then built into a candidate (`crates/dream-core/src/candidate.rs:296`); no trait or step in `seams.rs` (`crates/dream-core/src/seams.rs:18`-region, per ADR-DE-005:18's five-trait list) validates patch content or provenance. +- Failure: A prompt-injected or hallucinated patch is applied verbatim to the client's tree; with automerge enabled it ships. +- Confidence: low, inferred +- Severity: high +- Marked by authors: no + +### F-08 — Scripted results are indistinguishable from real ones at the seam +- Topics: CRT-02.1 +- Evidence: `ScriptedRunner` at `crates/dream-core/src/runner.rs:137`; the business text advertises "a full night on a laptop with scripted test results"; nothing stated marks a run as scripted in any durable output. +- Failure: A staging wiring is promoted to production unchanged; every candidate passes; nothing in the recorded night reveals the evaluator was canned. +- Confidence: inferred +- Severity: high +- Marked by authors: no + +### F-09 — The intended seam order is documentation, not code +- Topics: CRT-02.2 +- Evidence: "the sequence above is the intended host order from the crate docs (`crates/dream-core/src/lib.rs:3`). No driver in this repository enforces it, so the order is not machine-checked here" — author-flagged Open. +- Failure: A third-party adapter publishes the draft before re-running evaluators, or records before publishing; the engine's own code cannot prevent it. +- Confidence: high +- Severity: high +- Marked by authors: yes (Open) + +### F-10 — Automerge is a first-class seam method with no safety net behind it +- Topics: CRT-02.1 +- Evidence: `Forge` exposes `enable_automerge` and `required_status_checks` as trait methods (CRT-02.1 diagram, `crates/dream-core/src/seams.rs`), and the only implementation's branch-protection path is untested (`crates/dream-core/src/seams.rs:647`). +- Failure: Automerge succeeds while the required checks were never actually configured; merges proceed on green-from-nothing. +- Confidence: inferred +- Severity: high +- Marked by authors: no + +### F-11 — The deny check is a licence list, not a provenance or advisory check +- Topics: CRT-01.2 +- Evidence: "The licence list in `deny.toml` has no deny list; anything not allowed is denied" (`deny.toml:7`); the ban list targets host-tying crates (`deny.toml:26`); CI runs the `deny` job (`.github/workflows/ci.yml:71`, `docs/adr/ADR-DE-002-licence-and-provenance.md:42`). No advisory or sources check is mentioned. +- Failure: A compromised but permissively-licensed dependency enters the graph and passes every gate described here. +- Confidence: inferred +- Severity: medium +- Marked by authors: no + +### F-12 — Concurrency is implied but untested: NightLock with no CI coverage of its toolchain +- Topics: CRT-01.2, CRT-02.1 +- Evidence: `NightLock` exists and drives the 1.89 floor via `File::try_lock` (`Cargo.toml:7`); nothing builds on 1.89 (`.github/workflows/ci.yml:26`, Debt); the async seams return bare boxed futures with no runtime in the crate (`crates/dream-core/src/seams.rs:39`), leaving polling and cancellation wholly to the host. +- Failure: Two overlapping nights contend for the same workspace or memory file; the lock code — never built at its declared floor and untested here — misbehaves or is bypassed by a host that never polls the lock path. +- Confidence: low, inferred +- Severity: medium +- Marked by authors: no + +### F-13 — The intended order publishes before it records, leaving a side-effect-without-audit window +- Topics: CRT-02.2 +- Evidence: The asserted intended order is: evaluators → model → build/re-run → `publish_draft` and automerge (`crates/dream-core/src/persist.rs:298`) → "record the night, best effort" → notify (CRT-02.2 sequence, labelled "the order a host drives the seams in"). +- Failure: The process dies between publish and record — or the best-effort record then fails (`crates/dream-core/src/seams.rs:147`) — leaving an automerging PR live in the client's repository with no recorded verdict anywhere. +- Confidence: inferred +- Severity: high +- Marked by authors: no + +## gate / critical + +### F-01 — Candidate-authored code executes with the runner's privileges, and the guard's path enumeration cannot see files touched indirectly +- Topics: GAT-01.3, GAT-01.5 +- Evidence: GAT-01.3 Debt states the optional build command and the evaluators "execute the candidate's own scripts on the evaluation host through the runner" and "a candidate-added build script still runs with the runner's privileges; isolation is the host's job" (`crates/dream-core/src/candidate.rs:296`). GAT-01.5 Debt states the reader "cannot see files an evaluator reads indirectly (a `Makefile` behind `make test`, a `conftest.py` behind `pytest`); those must be named in the config's `protected` globs" (`crates/dream-gate/src/guard.rs:703`). The protected set is built only from evaluator-command words (`guard.rs:586`, `guard.rs:661`), config globs, and host-resolved extras (`crates/dream-core/src/replay.rs:143`). +- Failure: a patch adds or modifies `conftest.py` (not in any protected glob, not named by the evaluator command words). The guard passes, the evaluator imports it during collection, and the candidate's code runs with the runner's full privileges — credential theft, receipt tampering, or pivoting to the host — on the first night that evaluator is pytest-like. +- Confidence: high +- Severity: high +- Marked by authors: yes (two Debt markers cover the isolation gap and the indirect-file blind spot separately; the composed escape is not spelled out) + +### F-02 — All three guard checks inspect patch text only; candidate code that runs before or between evaluators can rewrite the evaluator scripts themselves, and receipts pin the command line, not the file contents +- Topics: GAT-01.3, GAT-01.4, GAT-01.5 +- Evidence: The three checks are all on patch text: before git (`crates/dream-core/src/candidate.rs:245`), on normalised text (`crates/dream-core/src/persist.rs:139`), and "on the applied text" in the gate (`crates/dream-gate/src/gate.rs:420`). The build command and evaluators run candidate scripts *after* those checks (`crates/dream-core/src/candidate.rs:296`, `candidate.rs:300`). Receipt validation compares "digest of command text" to the frozen digest (`crates/dream-gate/src/gate.rs:218`) — the command string, not the contents of the script it invokes. +- Failure: a candidate-added build script (running with runner privileges per the Debt) rewrites the protected `run_tests.sh` to `echo ok; exit 0` before evaluators run. The evaluator is not silent, the command-line digest still matches the manifest, every layer of the guard sees only the untouched patch text, and a broken or malicious change is recorded as ACCEPT. This defeats the gate even if the host sandboxes execution perfectly. +- Confidence: high +- Severity: high +- Marked by authors: no + +### F-03 — Predictable worktree path in the shared temp directory with remove-before-use is a local attack surface +- Topics: GAT-01.3 +- Evidence: "the candidate worktree path is predictable, `dream-wt-<8 hex of the repository path digest>-` under the shared temp directory, and any existing directory at that path is removed before use (`crates/dream-core/src/persist.rs:142`). On a multi-user host another local user can pre-create or race that path; the patch file that used to sit beside it is gone, but the worktree directory is not yet unpredictable." +- Failure: a local user watches for the path (fully derivable from the repo path and branch), races the removal, and re-creates the directory or symlinks it; the night's worktree, tree hash inputs, and evaluation happen in attacker-controlled storage — silently corrupted verdicts at minimum. +- Confidence: high +- Severity: high +- Marked by authors: yes (Debt) + +### F-04 — The deterministic worktree path also collides with the system's own concurrent or retried runs, cross-contaminating evaluated trees +- Topics: GAT-01.3 +- Evidence: The path is a pure function of repository path digest and branch (`persist.rs:142`) and any existing directory is removed before use. The material describes no locking or serialisation of candidate runs, and 8 hex digits (32 bits) also permits cross-repository collisions. +- Failure: a stuck night A is evaluating in `dream-wt-…-main`; an operator or scheduler starts retry B for the same repo and branch; B removes A's live worktree and rebuilds it with patch B. A's still-running evaluators now test B's tree, and A's receipts — recorded under A's attempt — describe B's behaviour. A patch with failing tests can be ACCEPTed on the strength of another run's clean tree. +- Confidence: medium, inferred (path determinism and removal are stated; run concurrency control is not shown) +- Severity: high +- Marked by authors: no (the Debt covers the external attacker, not self-collision) + +### F-05 — Harness-over-evidence precedence plus Missing-on-suppression lets one removed or broken receipt mask all recorded evidence of failure +- Topics: GAT-01.2, GAT-01.4, GAT-01 (For the business) +- Evidence: A missing, duplicate, or digest-mismatched required receipt is replaced by a synthesised Missing receipt (`crates/dream-gate/src/gate.rs:228`), which is a harness veto (`gate.rs:124`). Precedence: "any harness veto → BLOCKED-ENV (`gate.rs:502`)" outranks "else any evidence veto → REJECT (`gate.rs:504`)". The business section states BLOCKED-ENV "does not count against the repository" and explicitly contemplates tampered results, routing them to "broken test machine". +- Failure: receipts for evaluator A show Failed (evidence → REJECT); the receipt for evaluator B is deleted or its digest field broken (write access to the receipt store is nowhere excluded). B becomes Missing → harness veto → the whole night is BLOCKED-ENV and the recorded failure against the candidate vanishes. +- Confidence: high (mechanism fully stated; write-access precondition for the adversary is not shown) +- Severity: high +- Marked by authors: no + +### F-06 — A candidate can convert failing tests into BLOCKED-ENV by inducing a timeout or silence +- Topics: GAT-01.1, GAT-01.2 +- Evidence: TimedOut and Silent map to harness vetoes (`crates/dream-gate/src/gate.rs:124-138`), and any harness veto yields BLOCKED-ENV, which per the business section "does not count against the repository". The evaluators execute the candidate's own code (`crates/dream-core/src/candidate.rs:300`). +- Failure: a candidate whose tests would fail instead makes one test sleep past the evaluator timeout. The outcome is TimedOut → harness → BLOCKED-ENV: the failure is never counted, and if hosts retry blocked nights the model gets unbounded attempts to pass. +- Confidence: medium, inferred (mapping stated; the gaming payoff depends on unstated retry/accounting policy) +- Severity: high +- Marked by authors: no + +### F-07 — Receipts are graded only when the model claims ACCEPT; every other claim keeps "the model's own reading", so REJECT exists mainly when the model accuses itself +- Topics: GAT-01.2, GAT-01 (For the business) +- Evidence: Only the claimed-ACCEPT branch reaches candidate state and grading (`crates/dream-gate/src/gate.rs:405-418`); "A claim that is not ACCEPT keeps the model's own reading, or INCONCLUSIVE if unreadable (`gate.rs:493`)"; an unreadable verdict is an unproven veto (`gate.rs:407`) and skips grading entirely. +- Failure: a model whose patch fails tests emits a garbage verdict or any non-ACCEPT claim. Its failing receipts are never consulted, the night is recorded as INCONCLUSIVE (or the model's own word), and the evidence class the business section promises ("gathered and against the candidate") is never gathered. +- Confidence: high (bypass is stated; the deliberate-dodge framing is inferred) +- Severity: medium +- Marked by authors: no + +### F-08 — Environment breakages that exit non-zero are classed as evidence, contradicting the stated purpose of the class split +- Topics: GAT-01.1, GAT-01 (For the business) +- Evidence: "Failed exit code, ExplicitFail marker → evidence veto (`crates/dream-gate/src/gate.rs:141-146`)" → REJECT, while only missing/silent/blocked/timed-out are harness. The business section promises "a broken test runner is recorded as an environment problem and does not count against the repository." +- Failure: an OOM-killed test process, a missing toolchain, or a corrupted cache makes the evaluator exit non-zero on an unchanged repository. The night records REJECT, which counts against the repository — exactly the "confuses the test machine was down with the change was bad" failure the design claims to prevent, eroding trust in every REJECT record. +- Confidence: medium, inferred (mapping stated; frequency of non-zero env failures is world knowledge) +- Severity: medium +- Marked by authors: no + +### F-09 — Non-protected guard violations get different verdict classes depending on which layer sees them, and the guard's own docs disagree with the primary path +- Topics: GAT-01.2, GAT-01.3, GAT-01 (For the business) +- Evidence: Marked Open: `candidate::run` maps Protected to evidence state but "every other violation to Refused, which is unproven" → INCONCLUSIVE (`crates/dream-core/src/candidate.rs:207`, `crates/dream-gate/src/gate.rs:437`); the gate's re-check maps "every violation other than Protected to an evidence veto, patch-guard" → REJECT (`gate.rs:353`); "the guard's own docs tell callers to treat the non-protected violations as REJECT-class (`guard.rs:1160`)". The business section says an absurdly large patch "stops the night", but the live path records it like a no-patch claim: INCONCLUSIVE. +- Failure: the same TooLarge/malformed/symlink patch yields INCONCLUSIVE through `candidate::run` and REJECT on a replay under a changed policy or a caller that skipped the pre-apply check. Verdict statistics conflate classes; a hostile model prefers the INCONCLUSIVE path; auditors cannot reconstruct which class a refusal belonged to. +- Confidence: high +- Severity: medium +- Marked by authors: yes (Open) + +### F-10 — The lenient reader's "never fails and over-approximates" claim is unsupported for exactly the constructs strict mode rejects +- Topics: GAT-01.5 +- Evidence: `evaluator_command_paths` "never fails and over-approximates, and is kept for callers that are not freezing a night (`crates/dream-gate/src/guard.rs:703`)", while strict mode errors on command substitution because `$(` or backticks make the touched-file set unknowable (`guard.rs:536`, `guard.rs:557`). A static reader that skips those constructs cannot enumerate the files they touch, so the approximation direction for them is downward, not upward. +- Failure: a non-night live caller builds its policy with the lenient form for an evaluator like `cat $(ls fixtures)/setup.env`; the files actually read are not in the protected set; a patch modifies one of them and the guard passes. +- Confidence: medium +- Severity: medium +- Marked by authors: no + +### F-11 — Strict refusal of command substitution anywhere outside single quotes blocks legitimate evaluator commands and stops the night outright +- Topics: GAT-01.5 +- Evidence: Marked Tension: "a legitimate evaluator such as `cargo test $(nproc)` stops the night as UnsupportedCommand (`crates/dream-gate/src/guard.rs:557`)" and `guard.rs:169` makes this a "the night must not run" error. +- Failure: an evaluator config containing any `$(…)`, backtick, `$(pwd)`, or shell-computed path — routine in real CI commands — prevents the nightly function from starting at all, with no override path except rewriting the frozen config; production evaluation is unavailable until a human edits configuration. +- Confidence: high +- Severity: medium +- Marked by authors: yes (Tension) + +### F-12 — `complete_receipts` still trusts receipt self-fields, reintroducing the forged-requiredness escape for any caller that uses it +- Topics: GAT-01.4 +- Evidence: Marked Debt: "`complete_receipts` remains for callers holding only identities and trusts the receipt's own fields (`crates/dream-gate/src/gate.rs:157`); `decide` does not use it." The whole GAT-01.4 section exists because receipts self-describing requiredness let forgeries escape the veto set. +- Failure: reporting, tooling, or a future call site uses `complete_receipts`; a receipt that marks itself advisory or renames its command drops out of the veto set there, and two components of the same system disagree about whether a night was valid. +- Confidence: medium (existence and trust model stated; which callers use it is not shown) +- Severity: medium +- Marked by authors: yes (Debt) + +### F-13 — The config-drift harness check runs only on the claimed-ACCEPT, tree-trusted path +- Topics: GAT-01.2, GAT-01.5 +- Evidence: The digest check is step 3, behind `tree_trusted?` (`crates/dream-gate/src/gate.rs:461-471`) and behind claimed-ACCEPT (`gate.rs:405`); non-ACCEPT claims exit at `gate.rs:493` and guard refusals skip step 3. The only other drift guard is `ProtectedPolicy::for_manifest` refusing mismatched config bytes at policy-build time (`guard.rs:340`). +- Failure: a decide-only caller or replay path that does not build its policy through `for_manifest` records verdicts under a drifted config with no harness veto, whenever the claim was not ACCEPT or the guard refused — drift is detected only on the happy path. +- Confidence: medium (skipping is stated; which callers skip `for_manifest` is not) +- Severity: low +- Marked by authors: no + +### F-14 — The prose assumes the guard "said the patch did not touch the config", but the config file is never listed among protected paths +- Topics: GAT-01.2, GAT-01.5 +- Evidence: "The digest check runs only after the guard has said the patch did not touch the config (`crates/dream-gate/src/gate.rs:467`)" — yet the material's only protection sources are evaluator-command word paths (`guard.rs:586`, `guard.rs:661`), the config's `protected` globs, and host-resolved extras (`crates/dream-core/src/replay.rs:143`). Nowhere is the config file itself stated to be protected. +- Failure: if the night's config lives in the repository and is not matched by a protected glob, a candidate patch edits it; the decide-time digest then differs, producing a harness veto (`gate.rs:471`) → BLOCKED-ENV: the candidate's own sabotage of the rules is recorded as an environment problem and not counted against it. +- Confidence: low, inferred (the gap is between an asserted guard conclusion and the enumerated protection sources; whether config is in-repo is not shown) +- Severity: medium +- Marked by authors: no + +### F-15 — No after-use cleanup or crash recovery for worktrees is described; only removal-before-next-use +- Topics: GAT-01.3 +- Evidence: The only lifecycle statement is "any existing directory at that path is removed before use (`crates/dream-core/src/persist.rs:142`)". No post-run cleanup, no crash-safe teardown, and no recovery of in-flight attempt state appears anywhere in the material. +- Failure: a host crash or kill mid-night leaves `dream-wt-*` directories in the shared temp volume on every interruption; stale worktrees accumulate until a later run happens to reuse the identical repo+branch path, filling the temp filesystem and degrading every tenant of that volume. +- Confidence: low, inferred (absence-based; persist.rs internals beyond the cited lines are not shown) +- Severity: low +- Marked by authors: no + +## Not judgeable from this material + +1. Provenance and integrity of the frozen manifest — who freezes it, whether the model or candidate process can influence its required-evaluator list, command digests, or extras, and whether it is authenticated. The entire gate is a pure function of this input; its custody is decisive for F-05, F-06 and F-07. +2. How `tree_trusted` and the tree hash are computed — whether the recorded tree is cryptographically bound to baseline plus applied patch. `decide` re-checks patch text (`gate.rs:420`), never tree contents, so acceptance integrity rests on an input whose derivation is not shown. +3. The runner's actual isolation — user, container, namespace, network policy for the build and evaluator processes. The Debt says "isolation is the host's job", but the host's controls are undescribed, which determines the real blast radius of F-01 and F-02. +4. Concurrency control and crash semantics — whether hosts serialise candidate runs per repository/branch, how git behaves with two worktrees on one branch, and what attempt/receipt state `persist.rs` durably records and what a restart mid-night loses or re-runs. This decides whether F-04 and F-15 are live. +5. The model's verdict vocabulary and downstream accounting — which non-ACCEPT readings exist (does a model-issued BLOCKED-ENV pass through verbatim per `gate.rs:493`?) and how INCONCLUSIVE versus BLOCKED-ENV nights are counted, retried, or billed. This determines the payoff of the gaming paths in F-05 through F-07. + +## gate / premortem + +Post-mortem. Written from the stamped distillation at commit `0f6a66913c072603b6e29a80bdc67350737b9b21`. Every claim below cites the material; where I reason past what the text states I mark it inferred. No source code beyond the citations was available. + +## Root causes, most likely first + +### R-1 — Candidate-authored code executes with the runner's privileges on the evaluation host +- Topics: GAT-01.3 +- Chain of events: A candidate patch adds or modifies a script that the fixed pipeline executes — a build entry file, a repo-local helper, anything the optional candidate build command or a required evaluator invokes. The guard checks only the *patch text* against protected paths (`crates/dream-core/src/candidate.rs:245`, `crates/dream-core/src/persist.rs:139`, `crates/dream-gate/src/gate.rs:420`), so adding new executable content is not itself a violation. The optional candidate build command then runs it (`crates/dream-core/src/candidate.rs:296`), followed by the required evaluators (`crates/dream-core/src/candidate.rs:300`), both through the runner, with the runner's privileges. The script reads what the runner can read — credentials for the git remote, other worktrees, receipt storage, the manifest — and exfiltrates or rewrites it. +- Evidence: The material states this outright as Debt: "a candidate-added build script still runs with the runner's privileges; isolation is the host's job" (`crates/dream-core/src/candidate.rs:296`). No isolation layer appears anywhere in the three flow diagrams; the only containment mechanism in the entire topic is protected-path checking of patch text. The policy is built from the *baseline* config (`crates/dream-core/src/candidate.rs:141`), so the candidate cannot weaken the rules — but the rules only constrain which paths a patch may touch, not what executed code may do. +- Warning signs visible today: The Debt sentence itself, and the fact that the guard's entire threat model is textual (patch bytes, header lines, file counts — `crates/dream-gate/src/guard.rs:977`–`1009`) while the execution model is arbitrary (any script the fixed commands reach). "Isolation is the host's job" is a deferred control with no owner in the diagrams. +- Confidence: high that the defect exists (stated verbatim); the specific breach chain is inferred. +- Severity: high + +### R-2 — Unprotected indirect harness files let a candidate forge its own green light +- Topics: GAT-01.5, GAT-01.1 +- Chain of events: A candidate patch modifies a file the evaluator reads *indirectly* — a `conftest.py` behind `pytest`, a `Makefile` target behind `make test`. The frozen command `pytest` contains no path-like words, so `word_path` derives no protection from it (`crates/dream-gate/src/guard.rs:586`); the file is not in the config's `protected` globs; the patch applies cleanly. The evaluator runs on the candidate tree (`crates/dream-core/src/candidate.rs:300`), auto-loads the tampered harness file, and the tampered harness neutralises the checks and produces normal-looking passing output. The receipt is genuine: the command digest matches the frozen manifest (`crates/dream-gate/src/gate.rs:218`). `veto_for` sees a `Passed` outcome and produces no veto (`crates/dream-gate/src/gate.rs:123`). `decide` finds an empty veto list with a claimed ACCEPT and returns ACCEPT (`crates/dream-gate/src/gate.rs:489`). The malicious payload rides the same patch into the repository. +- Evidence: The Debt line names the gap exactly: "the reader cannot see files an evaluator reads indirectly (a `Makefile` behind `make test`, a `conftest.py` behind `pytest`); those must be named in the config's `protected` globs" (`crates/dream-gate/src/guard.rs:703`). The business text claims "a test result that has been tampered with … is treated as a broken test machine" — but the tamper detection described is receipt-versus-manifest (`crates/dream-gate/src/gate.rs:197`–`228`), which a harness-file tamper fully satisfies with a valid receipt. The silent-evaluator rule (`crates/dream-gate/src/gate.rs:128`) does not help: the tampered harness prints output. +- Warning signs visible today: The Debt sentence; the fact that protection from commands is limited to "path-like words and known baseline files" (`crates/dream-gate/src/guard.rs:586`), which for `pytest`/`make`/`cargo test` style commands is an empty set, leaving the entire protection burden on operator-maintained globs every night. +- Confidence: high that the protection gap exists (explicit); medium — inferred — that it produced a forged ACCEPT in production. +- Severity: high + +### R-3 — Predictable, pre-removed worktree path under the shared temp directory +- Topics: GAT-01.3 +- Chain of events: The candidate worktree lands at `dream-wt-<8 hex of the repository path digest>-` under the shared temp directory, and "any existing directory at that path is removed before use" (`crates/dream-core/src/persist.rs:142`). Two failure modes follow. (a) Cross-user: another local user pre-creates or races that path; between the removal and the worktree creation the attacker controls the directory contents, and a checkout into a directory seeded with symlinks writes outside the tree — or the pre-removal itself deletes attacker-chosen content. (b) Same operator: two concurrent nights on the same repository and branch deterministically share the path — no hash collision needed, the branch component is predictable — and one run removes the other's in-flight worktree, producing a partial or wrong tree that is then evaluated and graded. +- Evidence: Stated as Debt: "On a multi-user host another local user can pre-create or race that path; the patch file that used to sit beside it is gone, but the worktree directory is not yet unpredictable" (`crates/dream-core/src/persist.rs:142`). The prior symlink fix covered only the patch file, by feeding verified bytes to `git apply` on stdin (`crates/dream-core/src/git.rs:23`, `crates/dream-core/src/persist.rs:168`) — showing this attack class was already realised once against this system. The digest is 8 hex characters, i.e. 32 bits, with no per-run salt. +- Warning signs visible today: The Debt sentence; the 32-bit digest; the branch-keyed naming; the history of a sibling symlink vulnerability at the same directory. +- Confidence: high that the weakness exists (stated); the exploitation and self-collision scenarios are inferred (the material asserts removal and predictability, not the race outcome). +- Severity: high + +## Further latent defects + +### F-01 — Legacy `complete_receipts` still trusts self-describing receipts +- Topics: GAT-01.4 +- Evidence: `decide` now validates receipts against the frozen manifest because "earlier the required set came from the receipts, so a forged receipt could take itself out of the veto set" (`crates/dream-gate/src/gate.rs:197`, `crates/dream-gate/src/gate.rs:217`); yet "`complete_receipts` remains for callers holding only identities and trusts the receipt's own fields (`crates/dream-gate/src/gate.rs:157`); `decide` does not use it." +- Failure: Any caller other than `decide` — host summarisation, a replay path, a baseline precheck — grades through `complete_receipts`, and a receipt that downgrades itself to advisory or omits itself escapes the veto set: exactly the forgery hole that was fixed, still shipped at a second entry point. +- Confidence: high that the legacy path exists and trusts receipt fields; medium — inferred — that a production caller invokes it. +- Marked by authors: yes (Debt) +- Severity: high + +### F-02 — Files written by the candidate build step are never guard-checked +- Topics: GAT-01.3 +- Evidence: Every guard check in the system operates on patch text — before any git command (`crates/dream-core/src/candidate.rs:245`), on the normalised text in the worktree build (`crates/dream-core/src/persist.rs:139`), and again inside `decide` (`crates/dream-gate/src/gate.rs:420`). The optional candidate build command executes candidate-influenced code on the candidate tree *before* the evaluators run (`crates/dream-core/src/candidate.rs:296` → `crates/dream-core/src/candidate.rs:300`). +- Failure: Even an operator who followed the R-2 mitigation and listed `conftest.py` in the protected globs is exposed: the build step overwrites or generates the harness file at build time. The change exists in no patch, so no guard layer ever sees it, and the evaluators load it. +- Confidence: medium — inferred (the order is asserted by the sequence diagram; the unchecked-write consequence is my inference). +- Marked by authors: no (the Debt at `candidate.rs:296` covers privilege, not the write channel) +- Severity: high + +### F-03 — Guard refusal skips receipt grading, so REJECT can mask real harness faults +- Topics: GAT-01.2 +- Evidence: "When the re-check refuses the patch, `tree_trusted` goes false and step 3 is skipped, so the candidate's receipts are not graded. Harness faults outrank evidence in general, but a refused patch skips grading, so a guard veto can never be diluted into `BLOCKED-ENV`" (`crates/dream-gate/src/gate.rs:500`). +- Failure: A replay under a changed policy (explicitly reachable — see the Open text) or any environment fault coincident with a refused patch yields REJECT attributed to the candidate, while the genuine harness faults are never even collected. The operator blames the model for what is operator or environment drift — the exact distrust spiral the business section warns against. +- Confidence: high that the masking occurs (it is the stated mechanism); medium — inferred — for its operational consequence. +- Marked by authors: no (the authors present the skip as a feature) +- Severity: medium + +### F-04 — Non-Protected guard violations end the night INCONCLUSIVE, not REJECT, in the standard flow +- Topics: GAT-01.3 +- Evidence: "Before apply, `candidate::run` maps `Protected` to the evidence state but every other violation to `Refused`, which is unproven (`crates/dream-core/src/candidate.rs:207`, `crates/dream-gate/src/gate.rs:437`) … a refused patch therefore ends `INCONCLUSIVE`", while the guard's own docs "tell callers to treat the non-protected violations as `REJECT`-class for consistent verdicts (`crates/dream-gate/src/guard.rs:1160`)". +- Failure: Symlink, escape, binary and too-large probing by a model is recorded as "nothing to test". Alerting or triage keyed on REJECT never sees security-relevant attempts, and verdict statistics undercount hostile behaviour. +- Confidence: high (stated behaviour). +- Marked by authors: yes (Open) +- Severity: medium + +### F-05 — Verdicts are not reproducible under a changed replay policy +- Topics: GAT-01.2, GAT-01.5 +- Evidence: The invariant claims "the live guard, the gate and a later replay agree (`crates/dream-core/src/replay.rs:173`)", yet the Open text states the REJECT mapping "is reached … by a caller that skipped the pre-apply check or a replay under a changed policy". +- Failure: After an incident, an auditor replays a night with edited protected globs; the same frozen inputs produce REJECT where the live run produced ACCEPT (or the reverse). The post-incident record of what the gate allowed diverges from what it actually allowed. +- Confidence: medium-high (both statements are in the material; the contradiction between them is my observation). +- Marked by authors: yes (Open) +- Severity: medium + +### F-06 — The gate re-check's input is described inconsistently: patch text vs applied text +- Topics: GAT-01.2, GAT-01.3 +- Evidence: GAT-01.2 says the gate runs "guard::check on the patch text again (`crates/dream-gate/src/gate.rs:420`)"; GAT-01.3's sequence says "guard check once more on the applied text" at the same citation. Meanwhile normalisation "synthesises headers and could name a path the original did not" (`crates/dream-core/src/persist.rs:127`), and only the worktree build re-checks the normalised text (`crates/dream-core/src/persist.rs:139`). The gate re-check is "the second line for callers who skipped the first" (`crates/dream-gate/src/gate.rs:367`). +- Failure: If the gate checks pre-normalisation text, a caller that skipped `candidate::run`/persist but still reports an `Applied` state gets a synthesised protected path — e.g. into the config — past the last line of defence, and the digest check then misattributes the resulting drift. +- Confidence: low-medium — inferred (documentation ambiguity; cannot be resolved from the material). +- Marked by authors: no +- Severity: medium + +### F-07 — Lenient command reader is still shipped and never fails +- Topics: GAT-01.5 +- Evidence: The strict reader exists because a command that "cannot be read with certainty is an error rather than an approximation" (`crates/dream-gate/src/guard.rs:169`, `crates/dream-gate/src/guard.rs:489`); the lenient "`evaluator_command_paths`, never fails and over-approximates, and is kept for callers that are not freezing a night (`crates/dream-gate/src/guard.rs:703`)". +- Failure: A live tooling path uses the lenient form; a command containing `$(...)` or unusual quoting yields a guessed path set, an evaluator entry file is left unprotected, and the R-2 chain proceeds through the lenient reader. +- Confidence: low — inferred (the callers are unnamed). +- Marked by authors: no +- Severity: medium + +### F-08 — `word_path` can protect coincidental baseline names +- Topics: GAT-01.5 +- Evidence: Protection derives from "path-like words and known baseline files" (`crates/dream-gate/src/guard.rs:586`). +- Failure: An evaluator word coincides with a baseline file name; a legitimate patch touching that file trips a spurious `Protected` violation and the night records REJECT against a good change. +- Confidence: low — inferred. +- Marked by authors: no +- Severity: medium + +### F-09 — Provenance of `tree_trusted` is unstated +- Topics: GAT-01.2 +- Evidence: The flow gates step 3 on "tree_trusted?" (`crates/dream-gate/src/gate.rs:461`) immediately after the gate's own re-check, but nothing in the material states who computes the flag or from what; persist returns only "tree hash and applied patch". +- Failure: If the flag arrives from caller-supplied state, a forged false skips receipt grading and hides genuine harness faults behind an INCONCLUSIVE — the verdict stays fail-safe, but the record is wrong. +- Confidence: low — inferred (absence of provenance in the material). +- Marked by authors: no +- Severity: low + +### F-10 — Legitimate evaluators using command substitution stop every night +- Topics: GAT-01.5 +- Evidence: "command substitution is refused wherever it is not in single quotes, so a legitimate evaluator such as `cargo test $(nproc)` stops the night as `UnsupportedCommand` (`crates/dream-gate/src/guard.rs:557`)". +- Failure: A repository whose test command uses substitution blocks every night as BLOCKED-ENV until someone rewrites the evaluator; sustained unavailability of the pipeline. +- Confidence: high (stated). +- Marked by authors: yes (Tension) +- Severity: low + +### F-11 — Resource bounds make large-but-legitimate patches permanently ungateable, and give the model a cheap kill switch +- Topics: GAT-01.5 +- Evidence: Defaults of 4 MiB total, 2000 files, 8192-byte header lines (`crates/dream-gate/src/guard.rs:192`); "Any bound exceeded fails closed (`crates/dream-gate/src/guard.rs:963`)"; a refused patch ends the night INCONCLUSIVE via `Refused` (`crates/dream-core/src/candidate.rs:207`, `crates/dream-gate/src/gate.rs:437`). +- Failure: A legitimate vendored dependency update above the bounds always ends INCONCLUSIVE; symmetrically, a model can end any night early and cheaply by emitting an oversized patch. +- Confidence: medium (bounds and mapping stated; the operational impact is inferred). +- Marked by authors: no +- Severity: low + +### F-12 — Only a binary *delete* refusal is documented at the candidate layer +- Topics: GAT-01.3 +- Evidence: The single documented binary refusal is "refuse a binary delete (`crates/dream-core/src/candidate.rs:237`)"; nothing states how binary additions or modifications are handled at that layer or in the gate's re-check. +- Failure: If binary add/modify is treated differently across the three guard layers, a binary blob rides a patch into the tree, or the same patch draws different verdicts depending on which layer sees it. +- Confidence: low — inferred (an absence in the material, not an asserted gap; to be checked against the code). +- Marked by authors: no +- Severity: low + +### F-13 — Advisory receipts' failures produce no signal +- Topics: GAT-01.4 +- Evidence: A receipt for a name the manifest does not require "is kept but forced advisory, so it cannot promote itself into the veto set (`crates/dream-gate/src/gate.rs:208`)"; grading via `veto_for` covers the validated required set (`crates/dream-gate/src/gate.rs:121`), and nothing grades advisory outcomes. +- Failure: A real check that has drifted out of the manifest — renamed, misconfigured — still runs, still fails, and the night ACCEPTs anyway; the team believes coverage exists that the gate never evaluated. +- Confidence: low-medium — inferred. +- Marked by authors: no +- Severity: low + +## nightly / critical + +### F-01 — The automerge gate treats a required-check *name* as protection; nothing requires reviews or protects the checks' own definitions +- Topics: NIT-03.1, NIT-03.2 +- Evidence: The tree's only protection test is the required-check list: "list empty or blank" → `NoRequiredChecks`, "at least one name" → `Allowed` (`crates/dream-core/src/automerge.rs:126`), and the facts `publish` gathers are only the PR base branch and that list (`crates/dream-core/src/persist.rs:314`, `persist.rs:316`). No branch of the tree consults required-pull-request-reviews or any property of the checks beyond name existence. The business section claims eligibility requires "branch protection with required checks" (`NIT-03`, For the business). But the check *workflows* a required name refers to normally live inside the very repository the candidate patch modifies — the protected-path guard exists precisely because candidates can modify repo content (ADR-DE-003, governing NIT-02) — and the decision tree never verifies that the named checks' definitions are protected. +- Failure: A repo opts in with one required check and zero required reviews. The nightly candidate weakens the workflow that the required check runs (unless that repo's protected-path config happens to cover it), the check goes green, and `gh pr merge --auto` merges unreviewed, self-certified model-written code to the default branch unattended. +- Confidence: high that the tree checks names only (the diagram is presented as the whole decision); the workflow-redefinition exploit is inferred +- Severity: high +- Marked by authors: no + +### F-02 — A crash after `Persisted` silently loses the publish, and the restart records `Complete` without asking the forge +- Topics: NIT-01.1, NIT-01.3, NIT-03.2 +- Evidence: `Persisted` is journalled before the draft pull request and ledger row (`crates/dream-core/src/runstate.rs:72`, `runstate.rs:615`), and "the restart finalises the night to `Complete` without reconciling against the forge" (`crates/dream-core/src/runstate.rs:401`). "Nothing records the push, the pull request URL or the ledger commit afterwards" (`NIT-03.2`, Debt), and the ledger idempotency key is "for any future reconciliation" — i.e., none exists today (`crates/dream-core/src/persist.rs:298`). +- Failure: Engine crashes (or is OOM-killed) between journalling `Persisted` and writing the ledger row. Restart returns `AlreadyComplete`, flips `Persisted`→`Complete`, and nothing anywhere flags the missing PR or ledger row. Worse: if `enable_automerge` already succeeded, the forge merges the PR asynchronously after the engine is dead — the merge lands with no URL, no ledger row, and a journal that says the night simply finished. Recovery depends on a human noticing and running `reset_night`. +- Confidence: high +- Severity: high +- Marked by authors: yes (Tension in NIT-01.1; Debt in NIT-03.2) + +### F-03 — An *absent* journal reads as Fresh: losing the one custody file re-arms a possibly-published night and resets its attempt budget +- Topics: NIT-01.2, NIT-01.1 +- Evidence: The hardening made every unreadable journal an error, but "absence is the only thing that reads as fresh" (`crates/dream-core/src/runstate.rs:341`), and the stated reason corrupt must not read fresh is that it "reopens a finished one and resets its attempt budget" (`crates/dream-core/src/runstate.rs:333`) — exactly the consequence of a *deleted or lost* journal on a finished night, which the same rule treats as benign. `begin`'s Fresh path consults neither the forge nor the ledger. +- Failure: A sync tool, mis-provisioned volume, or operator `rm` removes the run-state file of a night that already published. Next start: absent journal → `Fresh`, attempts 1 → the full pipeline re-runs and publishes a second PR and a second ledger row. The entire never-repeat-a-publish guarantee rests on a single file whose loss mode is the dangerous one, not the safe error. +- Confidence: high +- Severity: high +- Marked by authors: no + +### F-04 — "A NightLock is held across the whole night" is stated as delivered, yet no shipped code acquires it +- Topics: NIT-01.4, NIT-01 (intro, business) +- Evidence: The intro says "Since the audit hardening … A `NightLock` is held across the whole night (`crates/dream-core/src/runstate.rs:248`)" and the business section asserts "Two copies of the engine cannot work the same night at once." NIT-01.4's own Open marker contradicts both: "`begin` does not take it and nothing else in this repository calls `NightLock::acquire`, so an engine that skips it is not stopped here (`crates/dream-core/src/runstate.rs:261`)". The crate "ships the journal, not the driver" (`crates/dream-core/src/lib.rs:21`). +- Failure: Two engine processes (a cron overlap, a manually launched copy, a scheduler retry) both read an absent or pre-`Persisted` journal on the same night, both run to completion, both publish. The audit-fix narrative says the exclusion problem was solved; in the shipped code it is a comment. +- Confidence: high +- Severity: high +- Marked by authors: yes (Open in NIT-01.4) — but the contradiction with the intro and business claims is unflagged + +### F-05 — Exclusion is a per-directory `flock`; nothing coordinates two workers on two directories or hosts +- Topics: NIT-01.3, NIT-01.4, NIT-03.2 +- Evidence: The lock is "`flock`-style on `/.lock`" (`crates/dream-core/src/runstate.rs:274`) and "a night is identified by its directory" (ADR-DE-007, `crates/dream-core/src/runstate.rs:7`). The night key `(repo, date, night_id)` is named only as the idempotency key "for any future reconciliation" (`NIT-03.2`, Debt) — no current mechanism keys forge or ledger writes globally. +- Failure: Two workers (two hosts, or one host with two checkout directories) run the same repo/date/night. Neither lock nor journal is shared; each sees an absent journal, each returns `Fresh`, each publishes. The business guarantee holds for one directory and fails for two. +- Confidence: medium; inferred — deployment topology is not stated, but no cross-directory mechanism appears anywhere in the material +- Severity: high +- Marked by authors: no + +### F-06 — The persist-before-effects ordering is a caller contract; violation converts at-most-once into duplicate publishes +- Topics: NIT-01.1, NIT-01.4, NIT-03.2 +- Evidence: "the at-most-once ordering is a documented caller contract on `persist` … no code in this repository sequences it, so nothing here enforces it" (`crates/dream-core/src/runstate.rs:615`). Resume re-walks earlier steps (`runstate.rs:604`) with no per-night idempotency on the push or model call, and the ledger key is future work. +- Failure: A host that opens the PR before journalling `Persisted` (or whose model call has external side effects before persist) crashes: restart resumes at `Gated`/`CandidateEvaluated`, re-walks, and repeats the external effect. The design's headline property — never repeat a publish — is only as strong as unshipped, unverified driver code. +- Confidence: high +- Severity: high +- Marked by authors: yes (Tension in NIT-01.1) + +### F-07 — Replay verification is not tamper evidence: only two of the seven artifacts are digest-bound +- Topics: NIT-02 (intro), NIT-02.2 +- Evidence: "Only the report and the config are digest-bound, so editing a receipt's exit status, outcome and streams together with `decision.json` defeats `verify`" (`crates/dream-core/src/replay.rs:10`); verify's receipt check is self-consistency — re-classifying each receipt "from its exit status and streams" (`replay.rs:525`) — not independence. +- Failure: Anyone with write access to a run directory rewrites a failing receipt's exit status, outcome and streams plus the recorded decision; `verify` re-derives the forged verdict and passes. Any audit process that treats `verify` passing as integrity gets false assurance; the stated remedy ("a witness over the run directory") "lives elsewhere" (`replay.rs:18`) and is not shown to exist. +- Confidence: high +- Severity: medium +- Marked by authors: yes (Tension in NIT-02) + +### F-08 — `capture` is never invoked by the shipped system, so "any verdict can be re-derived" is conditional on unverified host behaviour +- Topics: NIT-02 (business), NIT-02.1 +- Evidence: Business: "Any verdict the nightly engine gave can be re-derived from the saved evidence" (`NIT-02`, For the business). Open marker: "nothing in this repository calls `capture` during a night. The host driver has to, so whether any real night is replayable depends on the host" (`NIT-02.1`). Combined with F-02: capture happens (if at all) after the gate phase, so the nights most in need of scrutiny — ones that crashed — are the least likely to have evidence. +- Failure: Production runs for months with no host-side capture call; an incident demands replay, and there is nothing to replay. Nothing in the tooling detects the omission, because absence of a run directory is not an error anywhere. +- Confidence: high +- Severity: medium +- Marked by authors: yes (Open in NIT-02.1) — the contradiction with the business claim is unflagged + +### F-09 — The lock can be held hostage by descriptors inherited into forked children, with no force path for the operator +- Topics: NIT-01.3, NIT-01.4 +- Evidence: The lock is per open file description (`crates/dream-core/src/runstate.rs:274`), and `acquire_within` "retries for a short while because a descriptor briefly duplicated into a forked child can make a just released lock look held" (`runstate.rs:295`) — i.e., the fd survives into children and the mitigation assumes the child is short-lived. `reset_night` "waits at most 500 ms" and fails `WouldBlock` (`runstate.rs:442`), and no force or steal operation appears in the material. +- Failure: A hung evaluator or `gh` subprocess spawned during a night inherits the lock fd and outlives the engine. The next night's start fails `Held`; the operator remedy for a blocked night (`reset_night`) also fails `WouldBlock` within 500 ms; recovery requires finding and killing an anonymous child nobody is told about. +- Confidence: medium; inferred — child lifetime and fd flags are not stated, but the stated fork problem implies inheritance +- Severity: medium +- Marked by authors: no + +### F-10 — Verify re-judges the decision but never re-runs the evaluation; honest-but-wrong evidence verifies clean +- Topics: NIT-02 (business), NIT-02.2 +- Evidence: `decide` "build[s] the policy, parse[s] the report strictly, call[s] gate::decide" over stored inputs (`crates/dream-core/src/replay.rs:305`); the three checks are the recomputed decision, receipt self-consistency, and the patch-vs-state guard (`replay.rs:485`). Receipts are never re-executed and nothing binds them to a checkout state (only report and config are digested, `replay.rs:10`). Business claims verify "catches accidents and drift" (`NIT-02`, For the business) — but only decision-layer drift. +- Failure: A flaky or non-deterministic evaluator suite, or a host bug that ran the evaluator against the wrong tree, records receipts that are internally consistent and faithfully re-judged: `verify` passes and certifies a verdict whose evidence was wrong from the start. +- Confidence: medium; inferred scope limit from what verify is documented to do +- Severity: medium +- Marked by authors: no (adjacent to, but distinct from, the marked tamper Tension) + +### F-11 — The entire forge path is exercised only against fakes before production +- Topics: NIT-03.2 +- Evidence: "The only test touching `GhForge` is a dyn-safety check (`crates/dream-core/src/seams.rs:647`), so the protection parsing, the draft-to-ready transition and the squash request have only been exercised against fakes." +- Failure: The first live nights are the first live test. Failure modes include protection responses parsed into the wrong refusal bucket, the draft-to-ready transition failing or firing at the wrong moment, and the squash request erroring — each discovered as a broken PR at merge time rather than in CI. +- Confidence: high +- Severity: medium +- Marked by authors: yes (Tension in NIT-03.2) + +### F-12 — An `enable_automerge` failure is terminal: no retry, PR left as draft, only an `automerge_error` field knows +- Topics: NIT-03.2 +- Evidence: "a forge failure after `Allowed` is recorded in `automerge_error` and the pull request stays a draft … there is no retry" (`crates/dream-core/src/persist.rs:327`). +- Failure: A transient GitHub 5xx during the one enable call permanently degrades an opted-in repo to manual merges; the PR sits as a draft with no scheduled retry and (per F-02) no reconciliation on restart. Users who trusted unattended merges find stale drafts accumulating. +- Confidence: high +- Severity: medium +- Marked by authors: yes (Open in NIT-03.2) + +### F-13 — `reset_night`'s all-or-nothing rollback covers returned errors, not process death; a mid-reset crash produces a journal-free night +- Topics: NIT-01.3, NIT-01.2 +- Evidence: The sequence is hard-link both archives, then remove both sources, with rollback "if the second archive cannot be made the first is deleted, and if a source cannot be removed the already removed ones are linked back" (`crates/dream-core/src/runstate.rs:468`) — rollback is failure-return-driven. The intro sells reset as a two-step archive that "cannot collide" and archives "both files or neither" (`NIT-01.3`). A crash after removing the run-state source but before rollback leaves no journal, which `begin` reads as `Fresh` with attempts 1 (`runstate.rs:540`) — contradicting the night-level attempt budget claim (`runstate.rs:567`) and feeding directly into F-03's dangerous absent-reads-fresh mode. +- Failure: Operator resets a bad night; the process dies between the two removals. The next engine start treats the night as brand new with a full budget, silently, with no record that a half-finished operator action occurred. +- Confidence: medium; inferred — the crash windows follow from the stated step order, but the write durability is unstated +- Severity: medium +- Marked by authors: no + +### F-14 — Two governing passages disagree on whether any code sequences the persist ordering +- Topics: NIT-01.1, NIT-03.2 +- Evidence: NIT-01.1 (Tension): "no code in this repository sequences it, so nothing here enforces it" (`crates/dream-core/src/runstate.rs:615`). NIT-03.2 (Debt): "`Persisted` is journalled before the forge request (`crates/dream-core/src/persist.rs:298`)" — a line inside this repository's `publish`, immediately before `publish_draft` at `persist.rs:305`. Either `dream-core`'s `publish` does sequence journal-then-forge (and NIT-01.1 is wrong), or it does not (and NIT-03.2 misdescribes the code). +- Failure: A maintainer trusting one document refactors the publish path (or skips adding sequencing in a new driver) believing the other document's claim about where the safety ordering lives; the at-most-once property is silently altered. For a system whose value proposition is doc-to-code fidelity, a contradiction on the single most safety-critical ordering is itself a defect. +- Confidence: high that the statements conflict as written; medium that runtime behaviour differs +- Severity: low +- Marked by authors: no + +### F-15 — The self-referential refusal is slug/config/checkout-contains; which checkout and what "contains" means are unstated +- Topics: NIT-03.1 +- Evidence: The three-part test is "the slug, a config whose protected set covers the gate crates, and a checkout that contains them" (`crates/dream-core/src/automerge.rs:136`, `automerge.rs:144`). The material never says whether the checkout inspected is the base or the post-patch state, nor how containment is matched (paths, crate names, dependencies). A candidate patch that *introduces* or *references* engine crates — rather than sitting in a checkout that already contains them — may not trip the third part. +- Failure: A patch that adds gate crates as dependencies (or into paths the matcher does not cover) to a repo whose slug and protected config don't match is judged `Allowed`; the "never to the engine itself" promise in the business section is narrower in code than in prose. +- Confidence: low; inferred — the material does not specify the checkout side or the matching rule +- Severity: medium +- Marked by authors: no + +## Not judgeable from this material + +1. **Journal write durability and atomicity** — whether the run-state save is temp-file+rename and `fsync`ed before the forge push. If `Persisted` can be lost to power failure after the push, the at-most-once claim degrades to duplicates, which is worse than the documented loss direction (F-02/F-06). +2. **The actual host driver** — its source, tests, and whether it takes the night lock, calls `capture`, orders persist before effects, and makes the model call/push idempotent per night key. The repo ships none of the sequencing that every business guarantee rests on (F-04, F-06, F-08). +3. **Deployment topology and night-directory conventions** — single host or many, per-date directories, shared filesystem. This decides whether the per-directory `flock` delivers the claimed exclusion and whether `WrongNight` can fire in normal operation (F-05). +4. **Which side evaluator-supplied `PolicyExtras::protected_paths` and evaluator configs are resolved from (baseline vs candidate), and the default protected set** — i.e., whether a candidate can weaken its own guard or the CI definitions that automerge trusts (F-01, F-15). +5. **Ledger semantics and forge details** — what branch/file the "ledger commit" lands on and when; whether `enable_automerge` targets non-draft PRs and what performs the draft-to-ready transition. Needed to judge the exploitability of F-01 and the reconciliation gap in F-02. + +## nightly / premortem + +# Post-mortem: client-loss incident (data / custody / security class) + +Prepared twelve months after the state documented at `verified_commit 0f6a669`. The topic material is the only source; every claim below carries the citation the topics give. The three root causes are ranked by likelihood against the incident class; each is a mechanism the material itself asserts or directly implies. Fifteen further latent defects found while tracing them follow. Where a scenario depends on ordering or concurrency the material does not assert, it is labelled inferred. + +--- + +### R-1 — A crash after the journal says `Persisted` silently swallows an accepted change; the restart reports the night `Complete` without asking the forge +- Topics: NIT-01.1, NIT-01.3, NIT-01.4, NIT-03.2 +- Chain of events: night gates to ACCEPT → host calls `persist` → journal advances to `Persisted` **before** any external effect → the process dies (reboot, OOM, kill) in one of the three documented windows: before `publish_draft`, after the push but before the PR URL is recorded, or before the ledger row is written → nothing anywhere records the forge push or the ledger commit → restart calls `begin`; `finished_night` is night-level and ignores the run id, so the answer is `AlreadyComplete`, and `Persisted` is finalised to `Complete` best-effort, without reconciling against the forge → the client's accepted change never ships while every record says the night succeeded; the only recovery is a manual operator `reset_night`, and detection depends entirely on a human noticing the absence of a PR. +- Evidence: `Persisted` is "deliberately entered first, so a crash can lose a publish but never repeat one" (runstate.rs:72); "Nothing records the forge push or the ledger commit either, so a crash after `Persisted` loses that night's publish and the restart finalises the night to `Complete` without reconciling against the forge" (runstate.rs:615, runstate.rs:401); the debt restates the windows — "whether before the pull request exists or after it exists but before its URL or ledger row is recorded" (persist.rs:298, runstate.rs:615); finalisation "is best effort and the answer does not depend on the write landing" (runstate.rs:419); the business text concedes it: "The price of never repeating a publish is that a crash at the wrong moment can lose one." +- Warning signs visible today: both governing topics carry authored Tension/Debt saying exactly this; "the night key `(repo, date, night_id)` is the idempotency key for **any future** reconciliation" — i.e., none exists; even where replay `capture` ran, `verify` re-derives the gate decision, not delivery, so it would pass on a night whose PR never existed (NIT-02.2). +- Confidence: high (the loss window and the non-reconciling restart are stated, not inferred). +- Severity: high + +### R-2 — The night lock is a documented contract that nothing enforces; two engines can work one night and both publish +- Topics: NIT-01.4, NIT-01.2 +- Chain of events: a deployment overlap (cron jitter, a retry after a page, a second host) starts two engine processes on the same night directory → `begin` does not take the `NightLock` and "nothing else in this repository calls `NightLock::acquire`, so an engine that skips it is not stopped here" → either both engines read an absent journal (both `Fresh`), or the later one reads the earlier's `Initialised` record and is told `Resumed` — and both `Fresh` and `Resumed` "say proceed" (runstate.rs:154) → both run the night, both `persist`, both publish; the night key is not used as a forge idempotency key today → on an automerge-opted repo whose facts satisfy `decide`, both pull requests can merge: two divergent candidates from one night land unreviewed. +- Evidence: the lock "spans check, freeze, execute and persist, so two engines cannot both read an absent journal and both publish" (runstate.rs:248) — the stated protection is the lock and only the lock; the Open note that `begin` does not take it and no in-repository caller exists (runstate.rs:261); the resumed path that would tell a second engine to proceed (runstate.rs:549, runstate.rs:557); the unused idempotency key (NIT-03.2 Debt). +- Warning signs visible today: the Open note itself; that the lock already needed fork-child retry handling (runstate.rs:295) shows deployment-level processes around the lock were a live concern. +- Confidence: medium (inferred — the unenforced contract is stated; the two-engine deployment is reasoned beyond the material). +- Severity: high + +### R-3 — Automerge can be `Allowed` by forge-protection parsing that has never run against live GitHub +- Topics: NIT-03.1, NIT-03.2 +- Chain of events: client repo opts into `auto_merge` in the protected config, with branch protection and required checks → a night ACCEPTs, `publish_draft` returns pushed plus a PR url (clean draft), `pr_base_branch` matches the expected base → `required_status_checks` parses the live GitHub response — code whose only test is a dyn-safety check, with "the protection parsing, the draft-to-ready transition and the squash request… only exercised against fakes" → twelve months of API drift or a parsing defect yields a **non-empty list of names** for a branch whose protection is actually off or empty — the one direction the policy does not defend against, because error, 404, unsupported and empty all refuse (automerge.rs:13, seams.rs:377) while a fabricated non-empty list does not → `decide` returns `Allowed`, `enable_automerge` fires, and the PR merges with no required checks having run: an unreviewed, unverified change lands on the client's branch. +- Evidence: the authored Tension (seams.rs:647; NIT-03.2); the enumerated fail-safe directions that presume the parser's non-empty answers are true (automerge.rs:13); the publish flow that queries protection once and hands the result to `decide` then `enable_automerge` (persist.rs:316, persist.rs:329, persist.rs:332). +- Warning signs visible today: the Tension itself; the whole refusal-based safety design resting on an integration surface with zero live coverage. +- Confidence: medium (inferred — the missing live testing is stated; the false-non-empty direction is reasoned). +- Severity: high + +--- + +### F-01 — Coordinated edits defeat replay verification; only the report and config are digest-bound +- Topics: NIT-02.2 +- Evidence: "Only the report and the config are digest-bound, so editing a receipt's exit status, outcome and streams together with `decision.json` defeats `verify`" (replay.rs:10); `verify` re-classifies receipts from their **stored** exit status and streams (replay.rs:525) and diffs against the recorded `decision.json` (replay.rs:413) — every link in that chain is a writable file. +- Failure: anyone with write access to a run directory rewrites a failing receipt to pass, adjusts `decision.json` to match, and `verify` certifies a change that never passed its checks. +- Confidence: high (stated). +- Severity: high +- Marked by authors: yes (Tension) + +### F-02 — The persist-before-publish ordering is as unenforced as the lock +- Topics: NIT-01.1, NIT-01.4 +- Evidence: the at-most-once ordering is "a documented caller contract on `persist`… no code in this repository sequences it, so nothing here enforces it" (runstate.rs:615, runstate.rs:72); NIT-01.4's Open groups it with the lock as a host contract (runstate.rs:261). +- Failure: a host that opens the draft PR before calling `persist` crashes after the push; the restart resumes at `Gated` (same run id — the ledger commit never happened), the host re-walks to `persist` and publishes again: the double publish the ordering exists to prevent. +- Confidence: high that it is unenforced; the violating host is inferred. +- Severity: high +- Marked by authors: yes (Tension) + +### F-03 — `reset_night` recovery can re-run a night whose pull request already exists +- Topics: NIT-03.2, NIT-01.3 +- Evidence: the loss window includes "after it exists but before its URL or ledger row is recorded" (persist.rs:298); "Recovery is the operator's `reset_night`, which archives the journal so the night runs again" (runstate.rs:441) and nothing reconciles with the forge first. +- Failure: a lost-publish night is reset; the unrecorded orphan draft PR still exists on the forge; the re-run pushes a second PR for the same night, and on an automerge repo both can merge divergent candidates. +- Confidence: medium (inferred — the duplicate is implied by no-reconciliation plus reset, not stated outright). +- Severity: medium +- Marked by authors: yes (Debt marks the non-reconciliation; the duplicate consequence is my extension) + +### F-04 — Nothing in this repository calls `capture` during a night +- Topics: NIT-02.1, NIT-02.2 +- Evidence: "nothing in this repository calls `capture` during a night. The host driver has to, so whether any real night is replayable depends on the host" (NIT-02.2 Open); there is also no historical corpus (replay.rs:20). +- Failure: an incident investigation reaches for the evidence chain and finds no `replay.json`/`decision.json` because the host never captured; the business promise that any verdict can be re-derived is unbacked for production nights. +- Confidence: high (stated). +- Severity: medium +- Marked by authors: yes (Open) + +### F-05 — `capture` destroys the prior receipts before writing the new ones +- Topics: NIT-02.1 +- Evidence: "receipts/baseline and receipts/candidate, cleared first" (replay.rs:348), justified by restarted nights having fewer receipts (replay.rs:340); "Nothing is written if judging fails" (replay.rs:333). +- Failure: `capture` clears both receipt directories, then judging fails or the process dies mid-capture; the only copies of the night's raw evaluation evidence are gone — for this attempt and any earlier one. +- Confidence: medium (inferred — the order is stated, the loss on interruption follows). +- Severity: medium +- Marked by authors: no + +### F-06 — `protected_paths` in `replay.json` is not digest-bound; the replayed policy can be silently downgraded +- Topics: NIT-02.1, NIT-02.2 +- Evidence: the policy extras, including `protected_paths`, are recorded in `replay.json` (replay.rs:367, replay.rs:111) and applied on replay (replay.rs:173); the only digest-bound files are the report and the config (replay.rs:10). +- Failure: deleting or shrinking the `protected_paths` array makes `verify` judge under a weaker policy; if the stored patch happens not to touch those paths the recomputed decision still matches, and `verify` certifies a night under a policy it did not run. +- Confidence: medium (inferred — a composition of two stated facts). +- Severity: medium +- Marked by authors: yes (the digest-binding gap is the authored Tension; this consequence is mine) + +### F-07 — `verify` re-derives, it never re-measures: evaluator receipts are trusted as inputs +- Topics: NIT-02.2 +- Evidence: "`verify` re-judges from the stored inputs" (replay.rs:6); its receipt check re-classifies "from its exit status and streams" (replay.rs:525) — stored values, not re-execution; only the guard is re-run, on stored artifacts (replay.rs:528). +- Failure: the evaluator harness records a wrong exit status at capture time (a bug, not tamper); `verify` recomputes the same wrong verdict and reports a match — the business claim that verify "catches accidents" holds for file accidents after capture, not for capture-time measurement errors. +- Confidence: medium (inferred). +- Severity: medium +- Marked by authors: no + +### F-08 — A night's automerge behaviour is absent from both the journal and the replay evidence +- Topics: NIT-03.2, NIT-02.1, NIT-01.1 +- Evidence: the `Phase` ladder ends at `Gated`/`Persisted`/`Complete` (runstate.rs:58); `capture`'s seven files include no automerge record (replay.rs:336–379); a forge failure after `Allowed` lives only in `automerge_error` on the publish outcome (persist.rs:327). +- Failure: after a disputed unattended merge, the run directory and journal cannot say whether automerge was allowed, enabled or refused, or why; the audit depends on unstated host handling of a return value. +- Confidence: medium (inferred). +- Severity: medium +- Marked by authors: no + +### F-09 — Branch protection is read once at publish; nothing re-validates before the auto-merge executes +- Topics: NIT-03.2, NIT-03.1 +- Evidence: `required_status_checks` is queried once, for "the branch the PR really targets", during `publish` (persist.rs:314–316) and the result goes to `decide` then `enable_automerge` (persist.rs:329, persist.rs:332); no later phase or check exists in the ladder, and there is no retry (NIT-03.2 Open). +- Failure: between the decision and the merge firing, the PR is retargeted or protection is relaxed; the merge proceeds under protection facts that no longer hold. (The material does not assert the timing between `enable_automerge` and the merge; the single read is what is stated.) +- Confidence: low (inferred). +- Severity: medium +- Marked by authors: no + +### F-10 — The only recovery path is designed to fail under a live engine, inviting kills that land in the loss window +- Topics: NIT-01.3, NIT-01.4 +- Evidence: `reset_night` "takes the night lock and waits at most 500 ms, so it cannot run under a live engine" (runstate.rs:442); the lock is held "across the whole night" (runstate.rs:248); the crash-after-`Persisted` loss is the documented cost (runstate.rs:615). +- Failure: an operator working a lost publish finds `reset_night` returning `WouldBlock` because the engine holds the lock for hours; the practical workaround — killing the engine — is precisely the crash that loses the next publish. +- Confidence: low (inferred). +- Severity: medium +- Marked by authors: no + +### F-11 — Which tree the host resolves evaluator-named protected paths against is unstated +- Topics: NIT-02.1 +- Evidence: `protected_paths` are "the evaluator-named files the host resolved against the repository" (replay.rs:111); the material never says baseline or candidate state, while the guard itself runs against the candidate. +- Failure: if resolution happens against the candidate-modified tree, a candidate that moves or shadows an evaluator-named path changes what gets protected; the live guard and every replay then faithfully protect the wrong set. +- Confidence: low (inferred — an ambiguity in the material, not an observed defect). +- Severity: high +- Marked by authors: no + +### F-12 — Two naming schemes for superseded manifests +- Topics: NIT-01.3 +- Evidence: the Open note: "the manifest archive on a moved baseline is named by run id (manifest.rs:249), the reset archive by timestamp (runstate.rs:452); two naming schemes for superseded documents." +- Failure: operator tooling or a runbook keyed on one scheme misses or misidentifies archives made by the other; during incident response a superseded manifest is believed current, or vice versa. +- Confidence: medium (inferred). +- Severity: low +- Marked by authors: yes (Open) + +### F-13 — A forge failure after `Allowed` leaves the PR a draft with no retry +- Topics: NIT-03.2 +- Evidence: "a forge failure after `Allowed` is recorded in `automerge_error` and the pull request stays a draft (persist.rs:327); there is no retry." +- Failure: a transient API error at `enable_automerge` leaves a change the client expected to merge unattended sitting as a draft indefinitely; nothing in this material alerts anyone. +- Confidence: high (stated). +- Severity: low +- Marked by authors: yes (Open) + +### F-14 — A duplicated descriptor in a forked child can make a free lock look held past the retry window +- Topics: NIT-01.4 +- Evidence: the lock is per open file description (runstate.rs:274); `acquire_within` retries only "for a short while because a descriptor briefly duplicated into a forked child can make a just released lock look held" (runstate.rs:295); the wait budget elsewhere is 500 ms (runstate.rs:442). +- Failure: a long-lived forked child inherits the descriptor; the next night's acquire exhausts its retry and fails with `Held`; what a host does with a `Held` error is unstated, so the night may simply be skipped. +- Confidence: low (inferred). +- Severity: low +- Marked by authors: no + +### F-15 — The two-attempt budget drops gate-accepted nights and parks flapping nights +- Topics: NIT-01.1, NIT-01.2 +- Evidence: the default budget is two, "one clean try plus one recovery" (runstate.rs:51); `Abandoned` is reachable from any non-terminal phase, including `Gated`, when the budget is spent (NIT-01.1); `begin`'s answer there is "record and move on" (runstate.rs:547). +- Failure: two consecutive infra crashes unrelated to the change abandon a night whose candidate had already passed the gate; the accepted verdict is shelved with only a journal record, and delivery depends on an operator noticing `Abandoned`. +- Confidence: medium (inferred — the mechanics are stated; the harm framing is mine). +- Severity: low +- Marked by authors: no + +## selection / critical + +### F-01 — Secrets in failure text are persisted to a shared directory with only volatility normalisation, not redaction +- Topics: SEL-02.1, SEL-02.4 +- Evidence: The crate claims only that "long opaque identifiers such as tokens never reach a persisted cause" (`crates/failure-fingerprint/src/lib.rs:15`). Normalisation replaces paths, long opaque ids and digit runs with placeholders (`crates/failure-fingerprint/src/lib.rs:177`, `crates/failure-fingerprint/src/lib.rs:190`); the persisted cause is exactly this normalised text (`crates/failure-fingerprint/src/lib.rs:410`), written as a per-repo file in a shared state directory (`crates/dream-core/src/streak.rs:94`). Nothing states that short or non-"opaque-looking" secrets (letter-only bearer tokens, usernames, internal hostnames, query strings) are removed; digit and path replacement are volatility rules, not redaction. +- Failure: An evaluator fails with an error message embedding a short-lived credential or internal endpoint; normalisation leaves it inside the first 200 characters; the cause is written to the shared state directory and retained for as long as the failure persists (SEL-02.4), readable by anything with access to that directory, with no stated retention bound or access control. +- Confidence: medium; inferred — the gap follows from what the "long opaque" qualifier leaves uncovered. +- Severity: high +- Marked by authors: no + +### F-02 — Deterministic fingerprint collisions from normalisation and 200-char truncation bypass the new-failure veto +- Topics: SEL-02.1, SEL-02.3 +- Evidence: The fingerprint is SHA-256 over task, stage and a cause normalised by replacing paths/ids/digit-runs with placeholders and truncating to 200 characters (`crates/failure-fingerprint/src/lib.rs:190`, `crates/failure-fingerprint/src/lib.rs:357`). Two genuinely different failures at the same task and stage whose normalised causes agree on the first 200 characters produce identical fingerprints, so the candidate's failure matches a baseline record and is classified `Persisting`, not `New` — the veto at `crates/dream-gate/src/pertest.rs:253`/`:261` never fires. The authors' Tension covers only 64-bit hash width (`crates/failure-fingerprint/src/lib.rs:263`); content collisions from truncation and digit/id erasure need no birthday luck. +- Failure: Baseline fails with a message whose normalised form is e.g. `expected items after : `; a candidate introduces a different defect at the same task whose normalised cause matches within 200 chars; the new failure is classified `Persisting`, no veto is raised, and the change ships with a new failure class and no signal anywhere. +- Confidence: medium — the truncation and placeholder rules are stated; the collision frequency is inferred. +- Severity: high +- Marked by authors: no (the marked Tension at SEL-02.1 covers only hash width) + +### F-03 — A failed parse of either run silently skips the fingerprint veto and NoRegression, not just statistics +- Topics: SEL-01.3, SEL-02.3 +- Evidence: Per-test evidence exists only when the manifest declares a `test_format` **and** "a parse of both runs succeeds; otherwise, the builder returns `None`" (`crates/dream-core/src/testfmt.rs:543`); with `None`, the evaluator is "judged by the receipt alone, no statistics" (`crates/dream-gate/src/pertest.rs:187`). The fingerprint veto (`pertest.rs:253`/`:261`) and NoRegression sit inside the per-test branch, after the `None` branch in the flowchart — so an unparseable candidate output, not only an undeclared format, bypasses the new-failure check. Contrast `pertest.rs:190`, where invalid *options* do get a harness veto; malformed *results* get neither a veto nor a flag. The authors' Debt marker records only "no statistics". +- Failure: A candidate truncates or subtly changes its test-output format so the candidate parse fails; the builder returns `None`; the change is judged on the receipt alone, introduces brand-new failures in that evaluator, and no evidence veto is raised — malformed output is treated as absence of evidence. +- Confidence: medium; inferred from the flowchart branch structure — what "receipt alone" validates is unknown. +- Severity: high +- Marked by authors: partly — Debt at SEL-01.3 notes missing statistics, not the veto bypass + +### F-04 — Policy `Inconclusive` is dropped, breaking the "say so when too many did not" promise; the two fault-classification layers are not verified equivalent +- Topics: SEL-01 (business), SEL-01.1, SEL-01.3 +- Evidence: The business text promises the rules "say so when too many did not" produce a real result, and the crate guard returns `Inconclusive` when the fault share exceeds 0.5 or fewer than 1 comparable exists (`crates/pairwise-gate/src/model.rs:283`, `crates/pairwise-gate/src/policy.rs:39`). But the engine acts only on `Reject`: "policy `Inconclusive` … adds no veto at all" (`crates/dream-gate/src/pertest.rs:304`), and the authors themselves state the per-test fault check (`pertest.rs:214`) and the crate-level fault notion "are not obviously equivalent". +- Failure: A candidate run whose outcomes lack scores in a way the per-test layer does not classify as a fault drives the policy `Inconclusive`; per-test evidence contributes nothing; if nothing else vetoes, the night's decision is made by layers that never saw the evidence, and the promised "say so" never becomes a veto or a report. +- Confidence: high for the drop-`Inconclusive` behaviour (stated); medium that the two fault layers diverge — that is the authors' own flagged uncertainty. +- Severity: high +- Marked by authors: yes (Tension, SEL-01.3) + +### F-05 — The ADR-mandated held-out (TrainDev) guard is never constructed by the nightly +- Topics: SEL-01.2, SEL-01.3 +- Evidence: The gate's options have no held-out field and the policy builder "never constructs `TrainDev`" (`crates/dream-gate/src/pertest.rs:131`, `crates/dream-gate/src/pertest.rs:314`); ADR-DE-004 — the governing document for this area — states no consumer supplies held-out evaluators (`docs/adr/ADR-DE-004-pairwise-selection-and-fingerprints.md:55`). The train/dev composition the ADR specifies is unreachable in the only consumer shown. +- Failure: A candidate that overfits or games the visible suite (leakage, suite-specific tuning) is selected every night, because the held-out rejection path cannot run; the regression ships carrying the gate's approval. +- Confidence: high +- Severity: high +- Marked by authors: yes (Debt, SEL-01.3) + +### F-06 — A 64-bit fingerprint collision silently defeats both the gate veto and stall detection +- Topics: SEL-02.1, SEL-02.4 +- Evidence: The identity is the first 8 bytes of SHA-256 (`crates/failure-fingerprint/src/lib.rs:263`); the authors state a collision between a new failure and a baseline failure "would classify the new one as `Persisting` and let it past the gate's new-failure veto, with no signal" (SEL-02.1), and the cross-night use keeps records for as long as the failure persists, "which is where a long-lived collision would matter" (SEL-02.4 Open). +- Failure: Over months of retained streak manifests, one collision merges a new environment fault into an old record; the stuck-harness pattern still matches the old record and the new fault is never surfaced; ADR-DE-004 names exactly this as its review trigger. +- Confidence: high — the behaviour is stated; the probability is the authors' claim. +- Severity: medium +- Marked by authors: yes (Tension SEL-02.1; related Open SEL-02.4) + +### F-07 — Cross-night state is one unreplicated file that fails open to empty on corruption, with no stated atomicity or locking +- Topics: SEL-02.4 +- Evidence: The streak state is "per-repo manifest file in the shared state directory" (`crates/dream-core/src/streak.rs:94`); "corrupt or absent means an empty manifest" (`streak.rs:124`) and "a corrupt manifest fails open to an empty one" (`streak.rs:261`). Nothing describes atomic write, backup, repair, or locking; nothing addresses two runs for the same repo writing the same file. +- Failure: A crash or disk-full mid-write corrupts the manifest; the next night loads an empty manifest, every previously persisting harness fault becomes `New`, the "some persisting, none new" stuck pattern can no longer match, and stall detection stays disabled with no error raised. Alternatively, two overlapping pipelines for one repo race on the file and last-write-wins silently loses a night's classification. +- Confidence: high for the fail-open reset (stated); medium and inferred for the torn-write and concurrency scenarios. +- Severity: medium +- Marked by authors: no + +### F-08 — A single fault on either side appears to block the entire night +- Topics: SEL-01.3, SEL-02.4 +- Evidence: "any fault, or a baseline test missing on candidate → harness veto, BLOCKED-ENV" (`crates/dream-gate/src/pertest.rs:214`, `pertest.rs:232`), and a "night ends BLOCKED-ENV with harness-class vetoes" (`crates/dream-core/src/streak.rs:68`). One flaky outcome in one evaluator ends the night blocked rather than downgrading that evaluator. +- Failure: One chronically flaky test or one intermittent testbed hiccup blocks nightly selection most nights; the engine produces no decisions, and the only escalation path (the stuck notice) additionally requires the same fingerprint to persist — see F-11 — so chronic intermittent flakiness yields neither decisions nor escalation. +- Confidence: medium; inferred — night-wide scope is read from the `streak.rs:68` wording, not shown explicitly. +- Severity: medium +- Marked by authors: no + +### F-09 — Known-flaky evidence-class failures have no cross-night memory and repeatedly veto good candidates +- Topics: SEL-01.3, SEL-02.3, SEL-02.4 +- Evidence: The gate's baseline manifest is built from the same night's baseline failures only (`crates/dream-gate/src/pertest.rs:253`); cross-night fingerprint memory exists only for harness-class vetoes — "only harness vetoes become observations" (`crates/dream-core/src/streak.rs:74`, `streak.rs:236`). An evidence-class failure that is flaky on baseline but absent on a given night classifies as `New` on the candidate (`pertest.rs:253`/`:261`) and also hits NoRegression's pass→fail. +- Failure: A test failing intermittently on baseline vetoes every candidate that touches it, night after night — "new" fingerprint one night, NoRegression the next; good changes are rejected and the suite accumulates flaky tests that can never be excused, because only harness-class faults earn cross-night forgiveness. +- Confidence: medium; inferred — both mechanisms are stated, the recurring false rejection follows from them. +- Severity: medium +- Marked by authors: no + +### F-10 — TrainDev's crate-level "never a silent pass" guarantee is void at the engine layer +- Topics: SEL-01.2, SEL-01.3 +- Evidence: Crate claim: in `TrainDev` a thin or fault-heavy held-out set "makes the whole decision `Inconclusive` and never a silent pass" (`crates/pairwise-gate/src/policy.rs:394`). Engine fact: only `Reject` is acted on — `Inconclusive` "adds no veto at all" (`crates/dream-gate/src/pertest.rs:304`). The guarantee cannot survive composition with the engine. +- Failure: When held-out evaluators are eventually supplied (the direction ADR-DE-004 states), a candidate whose train evidence selects but whose dev set is too thin or fault-heavy returns `Inconclusive`; per-test adds nothing; the decision falls through to other layers with no dev-set signal — precisely the silent pass the crate promises to prevent. +- Confidence: high — both facts are stated and the contradiction is direct. +- Severity: medium (latent until TrainDev is wired) +- Marked by authors: no (the drop-`Inconclusive` mechanism is the marked Tension at SEL-01.3; its contradiction with `policy.rs:394` is unmarked) + +### F-11 — The stuck-harness pattern cannot match intermittent or alternating faults +- Topics: SEL-02.2, SEL-02.4 +- Evidence: The stuck notice requires exactly "BLOCKED-ENV, some persisting, none new, none fixed" (`crates/dream-core/src/streak.rs:76`); a failure that disappears and returns is `New` again with its count reset (`crates/failure-fingerprint/src/lib.rs:782`), and `Fixed` records are dropped from the next manifest (SEL-02.2 diagram). +- Failure: A testbed with two alternating faults, or any environment failure with an occasional clean night in between, never satisfies "none new, none fixed"; a genuinely broken testbed is never escalated, compounding F-08's blocked nights. +- Confidence: medium; inferred — the state machine is stated, the escalation gap follows from it. +- Severity: medium +- Marked by authors: no + +### F-12 — The crate-level guard permits Select on one comparable pairing with half the data faulted +- Topics: SEL-01.1, SEL-01.2 +- Evidence: The guard inconclusive only when the fault share is strictly above 0.5 or fewer than 1 comparable exists (`crates/pairwise-gate/src/model.rs:283`, `crates/pairwise-gate/src/policy.rs:39`); a share exactly at the limit is tolerated (`model.rs:226`), and `Margin` selects whenever wins minus losses exceeds the margin (`crates/pairwise-gate/src/policy.rs:98`) — so Select on a single comparison with 50% faults is a legal crate outcome. The engine's `min_cases` (`pertest.rs:279`, default 20 at `pertest.rs:155`) currently masks this for the statistical policies, but the weak bound lives inside the reusable crate. +- Failure: A future consumer of `pairwise-gate` that does not replicate the engine's `min_cases` check selects a candidate on one data point, or on a run where half the pairings faulted. +- Confidence: high on the stated bounds; the misuse scenario is inferred. +- Severity: low +- Marked by authors: no (presented as deliberate under "Why it is this way") + +### F-13 — Candidate-induced hangs classified as faults would be misattributed to the harness +- Topics: SEL-01.3, SEL-02.4 +- Evidence: A fault on either side is excluded from the tally as an infrastructure-style outcome (`crates/pairwise-gate/src/lib.rs:9`, `model.rs:205`) and becomes a BLOCKED-ENV harness veto (`pertest.rs:214`/`:232`); harness vetoes are the only cross-night observations (`streak.rs:74`) and drive the stuck notice (`streak.rs:136`). +- Failure: Candidates whose tests hang or time out in ways classified as faults produce BLOCKED-ENV nights whose fingerprints persist; the harness-stuck notice fires and the team debugs the testbed while the trigger is the candidate stream. +- Confidence: low; inferred — depends on whether timeouts and hangs land in the fault class, which the material does not define. +- Severity: low +- Marked by authors: no + +### F-14 — `count` is not a consecutive-streak count and correctness depends on caller discipline +- Topics: SEL-02.2 +- Evidence: "`count` is the previous count plus one and not a strict consecutive count; callers who need strictness check `prev.step + 1 == step`" (`crates/failure-fingerprint/src/lib.rs:539`); a persisting record just increments (`lib.rs:576`). +- Failure: A night that fails to run, or crashes before the manifest write, is invisible to the count; downstream readers treating `count` as streak length overstate persistence, and any caller that forgets the adjacency check misreads a gap-separated recurrence as consecutive. +- Confidence: high +- Severity: low +- Marked by authors: yes (Debt, SEL-02.2) + +### F-15 — The 20-case statistical floor is a config field, not an enforced invariant +- Topics: SEL-01.2 +- Evidence: ADR-DE-004 and the code default both use 20 as the floor for the sign test and the Wilson bound, "and it is a field on the options" (SEL-01.2 Open; `crates/dream-gate/src/pertest.rs:155`). +- Failure: An operator sets `min_cases` low, or a future caller omits it; exact one-sided sign tests and Wilson bounds run on a handful of pairings and `Select` on noise; nothing in the crate enforces the ADR's floor. +- Confidence: high on configurability; the misconfiguration scenario is inferred. +- Severity: low +- Marked by authors: yes (Open, SEL-01.2) + +## Not judgeable from this material + +1. What "judged by the receipt alone" (`pertest.rs:187`) actually validates — this is the only remaining guard when per-test evidence is `None`, and it determines whether F-03 is a full gate bypass or a partial one. +2. The fault taxonomy: which runtime conditions (timeout, OOM, corrupted output, missing artefact) are classified as faults versus scored failures — needed to settle F-04's layer divergence, F-08's blocking frequency, and F-13. +3. Write atomicity, locking, and whether multiple nightly pipelines for one repo can run concurrently against the shared state directory — needed to confirm the concurrency half of F-07. +4. The exact normalisation thresholds — what counts as a "long opaque id", and the placeholder formats — needed to confirm F-01 (secret persistence) and to estimate F-02's collision rate on real failure texts. +5. How the final Select/Reject is decided above the veto layer, and whether policy `Inconclusive` is surfaced to operators anywhere — needed to confirm the "silent" in F-04 and F-10. + +## selection / premortem + +# Post-mortem — nightly selection incident (reconstructed from the SEL-01 / SEL-02 distillation) + +Scope note: this document is written twelve months after the material's `verified_commit`. Every claim below carries the citation the material gives; where I reason past what the material states, the confidence line says "inferred". Nothing here relies on diagram order that the diagrams do not actually draw. + +--- + +### R-1 — A fault-heavy candidate accumulates zero evidence vetoes, because policy `Inconclusive` is dropped and the one remaining guard is not proven equivalent to the crate's fault handling +- Topics: SEL-01.1, SEL-01.3 +- Chain of events: + 1. During the nightly comparison, a large fraction of the candidate's tasks come back with outcomes but no score; at the crate level these are faults. + 2. The tally excludes them — a fault "is never a win or a loss" (`crates/pairwise-gate/src/model.rs:205`); the guard sees a fault share above 0.5, or fewer than 1 comparable, and returns `Inconclusive` before any rule is consulted (`crates/pairwise-gate/src/policy.rs:39`). + 3. `pertest` maps only a policy `Reject` to an evidence veto; a policy `Inconclusive` "adds no veto at all" (`crates/dream-gate/src/pertest.rs:304`). Per-test evidence "can only add vetoes" (`crates/dream-gate/src/pertest.rs:12`), so with no other veto the layer is silent. + 4. The only remaining barrier is the step-1 harness veto on "any fault, or a baseline test missing on candidate" (`crates/dream-gate/src/pertest.rs:214`). + 5. The material itself warns the two layers "are not obviously equivalent". Any fault representation that reaches the crate guard but not the per-test fault check in step 4 leaves the night with no veto of either class. + 6. If promotion is veto-absence, as the additive design implies (the material does not state any positive-evidence requirement at the nightly level), the candidate ships with its real regressions never counted as losses (`crates/pairwise-gate/src/lib.rs:9`), and produces incorrect client-facing output. +- Evidence: fault-not-loss `crates/pairwise-gate/src/lib.rs:9`; guard `crates/pairwise-gate/src/model.rs:283`, `crates/pairwise-gate/src/policy.rs:39`; only `Reject` acted on and the equivalence doubt, both in the SEL-01.3 Tension note at `crates/dream-gate/src/pertest.rs:304`; harness veto conditions `crates/dream-gate/src/pertest.rs:214`, `:232`; additivity `crates/dream-gate/src/pertest.rs:12`. +- Warning signs visible today: the SEL-01.3 Tension paragraph states the drop of `Inconclusive` and the unproven equivalence in plain words; the guard sits before the rule (`crates/pairwise-gate/src/policy.rs:39`), so fault-heavy evidence can never reach a rejecting rule. +- Confidence: high that steps 1–4 are the stated design; medium, inferred, for step 5's concrete mismatch and for the promotion semantics in step 6. +- Severity: high + +### R-2 — A genuinely new failure inherits an unrelated baseline failure's fingerprint and is classified `Persisting`, so the new-failure veto stays silent and the candidate is promoted +- Topics: SEL-02.1, SEL-02.2, SEL-02.3, SEL-01.3 +- Chain of events: + 1. The candidate introduces a failure whose cause text normalises identically to an unrelated baseline failure in the same task and stage: digit runs become a number placeholder, long opaque ids and paths become placeholders (`crates/failure-fingerprint/src/lib.rs:190`), causes are truncated at 200 characters before hashing, or the two causes genuinely collide in the 64-bit identity (`crates/failure-fingerprint/src/lib.rs:263`, `:365`). + 2. The fingerprint is the first 8 bytes of SHA-256 over task, separator, stage, separator, normalised cause (`crates/failure-fingerprint/src/lib.rs:357`, `:365`) — same task, stage and normalised cause means the same identity, by construction. + 3. `advance` from the baseline manifest classifies the record `Persisting`, keeping the baseline's `first_seen_step` (`crates/failure-fingerprint/src/lib.rs:576`). + 4. The gate vetoes only `New` records (`crates/dream-gate/src/pertest.rs:255`, `:261`); "persisting and fixed records never veto" (SEL-02.3). + 5. Whether the pairing-based `NoRegression` in the policy stack (`crates/dream-gate/src/pertest.rs:326`) independently catches the failure is not stated in the material; where the failure does not move the pair's scores, the fingerprint veto is the only stated guard. + 6. The promoted candidate carries a new failure mode into production. +- Evidence: the authors' own words — a collision "would classify the new one as `Persisting` and let it past the gate's new-failure veto, with no signal", and ADR-DE-004 lists a collision seen in practice as its review trigger (`crates/failure-fingerprint/src/lib.rs:263` note); normalisation rules `crates/failure-fingerprint/src/lib.rs:190`; hashing `:357`, `:365`; Persisting semantics `:576`; veto mapping `crates/dream-gate/src/pertest.rs:255`, `:261`. +- Warning signs visible today: the SEL-02.1 Tension note; the placeholder rules themselves, which map distinct causes onto one normalised form; the 200-character truncation; the 64-bit identity. +- Confidence: high that any fingerprint match produces the silent bypass (stated); medium, inferred, that placeholder-merging or truncation produces such matches in this suite; low for a raw 64-bit collision as the trigger. +- Severity: high + +### R-3 — Credential material in a failure cause survives the normalisation heuristics and is persisted indefinitely in a per-repo manifest in the shared state directory +- Topics: SEL-02.1, SEL-02.2, SEL-02.4 +- Chain of events: + 1. A failure cause embeds a secret that is not path-shaped, not matched by the long-opaque-id heuristic, and not a bare digit run — for example a short alphanumeric key. + 2. `normalize_cause` alters only paths, long opaque ids, digit runs and whitespace, and truncates at 200 characters (`crates/failure-fingerprint/src/lib.rs:190`); everything else passes through. + 3. The invariant is that the persisted cause is always the normalised text — i.e. whatever survives is stored verbatim (`crates/failure-fingerprint/src/lib.rs:410`). + 4. The cross-night use writes observations to a per-repo manifest file in the shared state directory (`crates/dream-core/src/streak.rs:94`) and keeps records for as long as the failure persists (SEL-02.4 Open). + 5. Any reader of the shared state directory obtains the credential, and the record outlives the incident for as long as the failure keeps recurring. +- Evidence: the material claims only that "long opaque identifiers such as tokens never reach a persisted cause" (`crates/failure-fingerprint/src/lib.rs:15`) — a heuristic scope, not a guarantee over secrets generally; the persisted-text invariant `crates/failure-fingerprint/src/lib.rs:410`; the shared-directory manifest `crates/dream-core/src/streak.rs:94`; long retention noted in SEL-02.4 Open. +- Warning signs visible today: scrubbing is described as a side effect of normalisation rather than a security property; manifests live in a shared directory and persist across nights. +- Confidence: low, inferred — no leak is shown; the defect is the gap between the stated heuristic and secrets in general. +- Severity: high + +--- + +### F-01 — `TrainDev`'s "never a silent pass" guarantee is defeated one layer up, because the gate acts only on `Reject` +- Topics: SEL-01.2, SEL-01.3 +- Evidence: a thin or fault-heavy held-out set "makes the whole decision `Inconclusive` and never a silent pass" (`crates/pairwise-gate/src/policy.rs:394`), but `pertest` maps only `Reject` to a veto and `Inconclusive` "adds no veto at all" (`crates/dream-gate/src/pertest.rs:304`). +- Failure: even after the wiring debt in F-02 is paid, a thin dev evaluator still produces no gate veto; the silent pass recurs at the gate layer. +- Confidence: high — both statements are cited. +- Severity: high +- Marked by authors: no + +### F-02 — `TrainDev` and held-out evaluators are never wired into the nightly +- Topics: SEL-01.2 +- Evidence: the gate's options have no held-out field and the policy builder never constructs `TrainDev` (`crates/dream-gate/src/pertest.rs:131`, `:314`); ADR-DE-004 states no consumer supplies held-out evaluators yet (`docs/adr/ADR-DE-004-pairwise-selection-and-fingerprints.md:55`). +- Failure: a candidate overfit to the visible suite passes the train-only gate and is selected night after night. +- Confidence: high — the gap is stated. +- Severity: high +- Marked by authors: yes (Debt) + +### F-03 — Evaluators without a `test_format` get no per-test evidence at all +- Topics: SEL-01.3 +- Evidence: the builder returns `None`, never fabricated evidence (`crates/dream-core/src/testfmt.rs:543`); such evaluators are "judged by the receipt alone, no statistics" (`crates/dream-gate/src/pertest.rs:187`). +- Failure: a regression confined to such an evaluator is invisible to both the fingerprint veto and every policy veto; the change is promoted on the receipt alone. +- Confidence: high — stated. +- Severity: high +- Marked by authors: yes (Debt) + +### F-04 — Below `min_cases` the evaluator is one case, and `Floor` is candidate-only +- Topics: SEL-01.2, SEL-01.3 +- Evidence: below `min_cases` the evaluator counts as one case, judged by `NoRegression` and `Floor` only (`crates/dream-gate/src/pertest.rs:282`); `Floor` checks only candidate scores against the floor (`crates/pairwise-gate/src/policy.rs:156`). +- Failure: in a small evaluator, a candidate that degrades every score but stays above the floor and triggers no new fingerprint adds no veto. +- Confidence: high for the behaviour; medium for the harm. +- Severity: medium +- Marked by authors: no + +### F-05 — A fault share of exactly 0.5 is tolerated: the rule runs on half-missing evidence +- Topics: SEL-01.1 +- Evidence: only a share larger than the limit is inconclusive; exactly at the limit is tolerated (`crates/pairwise-gate/src/model.rs:226`). +- Failure: an evaluator judged with half its pairings faulted proceeds to the rule on the remainder, treating a half-evacuated evidence base as sufficient. +- Confidence: high — stated as deliberate. +- Severity: low +- Marked by authors: no + +### F-06 — A corrupt cross-night manifest fails open and silently disables stuck-harness detection +- Topics: SEL-02.4 +- Evidence: a corrupt or absent manifest loads as empty (`crates/dream-core/src/streak.rs:124`, `:261`); from an empty manifest every current harness fault is `New`, and the stuck notice requires "some persisting, none new" (`crates/dream-core/src/streak.rs:76`, `:136`). +- Failure: a partial write or corruption erases history; a genuinely stuck testbed is reclassified as all-new every night and never escalated. +- Confidence: high — both halves stated. +- Severity: medium +- Marked by authors: no + +### F-07 — A flapping fault is `New` forever, so stuck detection cannot fire for intermittent harness faults +- Topics: SEL-02.2, SEL-02.4 +- Evidence: `Fixed` → `New` on return with the count reset (`crates/failure-fingerprint/src/lib.rs:782`); the stuck notice requires some persisting records (`crates/dream-core/src/streak.rs:76`). +- Failure: a harness fault that recurs every other night is `New` on each return, never persisting, and is never escalated as stuck. +- Confidence: high for the mechanism; medium that it is a defect rather than intent. +- Severity: low +- Marked by authors: no + +### F-08 — `count` is cumulative, not consecutive; consumers must re-derive strictness themselves +- Topics: SEL-02.2 +- Evidence: `count` is the previous count plus one; callers needing strictness must check `prev.step + 1 == step` (`crates/failure-fingerprint/src/lib.rs:539`). +- Failure: a consumer reading `count` as consecutive nights overestimates streaks across gaps and mis-escalates. +- Confidence: high — stated debt. +- Severity: low +- Marked by authors: yes (Debt) + +### F-09 — Cross-night records are long-lived, which is where a 64-bit collision would matter +- Topics: SEL-02.1, SEL-02.4 +- Evidence: SEL-02.4 Open — the gate use needs only two trees, while the cross-night use "keeps records for as long as the failure persists"; the identity is 64 bits (`crates/failure-fingerprint/src/lib.rs:263`). +- Failure: two distinct harness faults sharing a fingerprint merge into one long-lived record; escalation and stuck classification are wrong indefinitely. +- Confidence: medium — the exposure is flagged by the authors; the occurrence is inferred. +- Severity: low +- Marked by authors: yes (Open) + +### F-10 — Suspected: crate-level `NoRegression` does not treat baseline-pass → candidate-fault as a regression +- Topics: SEL-01.1, SEL-01.3 +- Evidence: a fault pairing "is never a win or a loss" (`crates/pairwise-gate/src/model.rs:205`); `NoRegression` is described only as pass→fail (`crates/pairwise-gate/src/policy.rs:364`); the SEL-01.3 Tension says faults "are caught separately by the harness veto at step 1", implying the policy layer does not handle them; nothing in the material states that pass→fault rejects. +- Failure: a candidate-side fault on a previously passing test escapes the crate policy and rests entirely on the per-test "any fault" check — the same check whose equivalence the authors doubt (see R-1). +- Confidence: medium, inferred — it is an absence of statement, not a statement. +- Severity: high +- Marked by authors: no + +### F-11 — In the gate, the improvement policies can only reject; `Select` is inert, so the per-test layer appears to require the absence of vetoes rather than positive evidence +- Topics: SEL-01.2, SEL-01.3 +- Evidence: only `Reject` is acted on (`crates/dream-gate/src/pertest.rs:304`); per-test evidence "can only add vetoes" (`crates/dream-gate/src/pertest.rs:12`); `Margin`, `SignTest` and `WilsonLower` are in the gate's stack (`crates/dream-gate/src/pertest.rs:326`) but their `Select` outcome has no stated effect there. +- Failure: a candidate that improves nothing, triggers no new fingerprint and no pass→fail transition accumulates no veto and passes — whether any other layer requires positive evidence is not stated in the material. +- Confidence: medium, inferred — the per-test behaviour is stated; the promotion semantics are not. +- Severity: medium +- Marked by authors: yes (Tension — "only `Reject` is acted on") + +### F-12 — Task identity is part of the fingerprint, so task renaming makes known failures unmatchable +- Topics: SEL-02.1, SEL-02.3 +- Evidence: the identity hashes task, stage and cause (`crates/failure-fingerprint/src/lib.rs:357`); the gate rebuilds the baseline manifest from the baseline run each night (SEL-02.3, SEL-02.4 Open). +- Failure: the suite reindexes task names between the baseline and candidate runs; the candidate's known failures no longer match the baseline's, are all classed `New`, and good changes are falsely rejected — a chronic fail-closed condition. +- Confidence: medium, inferred — the mechanism is stated; the occurrence is not. +- Severity: low +- Marked by authors: no + +### F-13 — Any single fault anywhere raises a harness veto for the whole night +- Topics: SEL-01.3 +- Evidence: "any fault, or a baseline test missing on candidate" → harness veto, BLOCKED-ENV (`crates/dream-gate/src/pertest.rs:214`, `:232`). +- Failure: nightly promotions stall on any infra flake; the stated consequence is only blocking, but chronic blocking is the classic pressure that produces unvetted manual overrides — the override, not the block, would be the incident. +- Confidence: low, inferred — the blocking is stated; the override behaviour is not. +- Severity: low +- Marked by authors: no + +### F-14 — Truncation at 200 characters merges any long causes sharing a prefix +- Topics: SEL-02.1 +- Evidence: whitespace is collapsed and the cause truncated to 200 characters before hashing (`crates/failure-fingerprint/src/lib.rs:190`). +- Failure: two long, distinct causes (long stack traces with a shared header) are identical after truncation, share a fingerprint, and the second is hidden as `Persisting` — the same silent bypass as R-2 by a different route. +- Confidence: medium, inferred — the rule is stated; the merging follows from it. +- Severity: medium +- Marked by authors: no + +### F-15 — Within one step, observations with the same fingerprint are de-duplicated, first wins +- Topics: SEL-02.2 +- Evidence: same-fingerprint observations in one step are de-duplicated, first wins (`crates/failure-fingerprint/src/lib.rs:562`). +- Failure: several genuinely distinct failures that normalise identically in one run collapse into one record; the count under-reports and, in the gate's step 2, the second failure is invisible if the first matched the baseline. +- Confidence: medium, inferred. +- Severity: low +- Marked by authors: no