← Commit history

Require saved native board and rule binding before KiCad model acceptance (#50)

John Lauer ·74796cfe1f · ·parent c61d9be
4 files changed +125−1
SKILL.md+4
@@ -475,3 +475,7 @@ Copper measurement uses the same explicit `ADOM_AIFLOW_KICAD_READ_SOCKET` as pla ### Rechecking an adopted project's design rules  After board adoption, `rules check` records a fresh input-bound result when checking the run's current board and the profile named by its spec. It preserves the existing `.kicad_dru`, including justified local constraints. Checking an unrelated board or overriding the spec's profile does not qualify this run. A failure is recorded as failed evidence; native DRC and fabrication review remain required. Do not re-install or overwrite custom rules just to clear a stale timeline requirement.++### Rebind native saved files after adoption++Read `docs/native-board-binding.md` when the adopted board differs from the desktop checkpoint. Use `bind-remote-board` with the exact intended desktop path; `models` refuses mismatched board or required rule-sidecar bytes. This does not validate the live editor or waive engineering gates. Preserve older checkpoint files.
crates/adom-aiflow/src/main.rs+25−1
@@ -13,6 +13,7 @@ mod thermalvias; mod outline; mod browser_access; mod schematic_identity;+mod remote_binding; mod placement_verify; mod provenance; mod spec_freeze;@@ -116,9 +117,11 @@ enum Cmd {     Evidence { what: String, #[arg(long, value_parser = ["calcs", "simulation", "vendor-model"])] kind: String, #[arg(long)] file: String, #[arg(long)] note: Option<String> },     /// The live clips page: rebuild docs/clips.md (every clip: step, visit, time, the 10x cut, the action cut, the contact sheet, the suspect state, the note from clip-notes.json) and with --push put it and the clip media on the page. Once a page is set, every clip stop does this by itself.     Clips { #[arg(long)] page: Option<String>, #[arg(long)] push: bool },-    /// Inventory every board reference for the early component quality review; optional MPN marking is an explicit preference.+    /// Verify and bind the current snapshot to an exact saved native KiCad file.+    BindRemoteBoard { #[arg(long)] remote_board: String },     /// Adopt a native-saved board after AI-owned edits, preserving project rules and checking baseline DRC.     AdoptBoard { #[arg(long)] board: String, #[arg(long)] evidence: String },+    /// Inventory every board reference for the early component quality review; optional MPN marking is an explicit preference.     Components { #[arg(long, value_parser = ["ask", "off", "on"])] etch: Option<String> },     /// Optional Hydrogen progress widget: enable, disable, status, or publish an existing thumbnail.     Widget { #[arg(value_parser = ["enable", "disable", "status", "event"])] action: String, #[arg(long)] file: Option<PathBuf>, #[arg(long)] label: Option<String>, #[arg(long)] step: Option<String>, #[arg(long)] progress: Option<f64> },@@ -1989,6 +1992,16 @@ fn record_fields_solve(r: &mut Run, file: &Path, how: &str, args: &[String]) {     } } +fn checked_remote_binding(r:&Run,br:&Bridge,remote:&str,dir:&Path)->Result<Value,String>{+    let local=PathBuf::from(r.current_board());+    let files=remote_binding::files(&local,remote)?;+    let pull=dir.join(format!("native-binding-{}",std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e|e.to_string())?.as_nanos()));+    std::fs::create_dir_all(&pull).map_err(|e|e.to_string())?;+    let reply=br.call("pull_file",&json!({"filePaths":files.iter().map(|(_,p)|p).collect::<Vec<_>>(),"saveTo":std::fs::canonicalize(&pull).map_err(|e|e.to_string())?}));+    if !Bridge::ok(&reply){return Err(format!("native binding download failed: {}",reply["error"]))}+    remote_binding::verify(&local,remote,&pull)+}+ fn sha256_file(p: &str) -> String {     use sha2::{Digest, Sha256};     std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()@@ -2549,6 +2562,12 @@ fn main() {             if !status.success() { err("widget helper failed", &[]); }             ok("widget preference or artifact updated", &["Optional: open AI Flow progress from Hydrogen Widgets. Reads the run and existing artifacts without AI calls. Disable stops feed updates and new thumbnails.".into()]);         }+        Cmd::BindRemoteBoard { remote_board } => {+            thread(&cli);let mut r=load_run(&cli);let br=bridge_of(&r,&cli);+            let proof=checked_remote_binding(&r,&br,remote_board,&dir).unwrap_or_else(|e|err(&e,&["Transfer the current adopted board and unchanged project/rule sidecars to the intended desktop path, then bind again. This never edits the desktop file or accepts a gate.".into()]));+            let old=r.data["remoteBoard"].clone();r.data["remoteBoard"]=proof["remoteBoard"].clone();r.data["remoteBinding"]=proof.clone();r.log("bind-remote-board",json!({"previousRemoteBoard":old,"proof":proof}));r.save().unwrap();+            ok("native board and project rules match the adopted snapshot; desktop binding updated",&["Run models and verify the native 2D/3D views. Binding is not engineering acceptance.".into()]);+        }         Cmd::AdoptBoard { board, evidence } => {             thread(&cli); let mut r=load_run(&cli);             if evidence.trim().is_empty(){err("adopt-board needs --evidence describing native save/refill/read-back", &["Save and refill the board in its EDA, then pass --board <file> --evidence <review notes or report path>.".into()]);}@@ -2562,6 +2581,7 @@ fn main() {             std::fs::copy(board,&dest).unwrap_or_else(|e|err(&e.to_string(),&[]));             aiflow_copper::copy_project_rules(board,&dest.display().to_string()).unwrap_or_else(|e|err(&e,&[]));             r.add_board(&dest.display().to_string());+            r.data["remoteBinding"]=Value::Null;             r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary}));             r.save().unwrap();             ok("native-saved board adopted; historical snapshots preserved", &["Run plan, then route: it keeps the board's own tracks and vias and plans only the connections they leave open (a fully routed board gets an empty plan that gate, land route and finish accept). Re-solve Fields before finish.".into()]);@@ -4126,6 +4146,10 @@ fn main() {             let br = bridge_of(&r, &cli);             let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();             let _ = t;+            if !rb.starts_with(aiflow_bridge::fusion::PREFIX) {+                let proof=checked_remote_binding(&r,&br,&rb,&dir).unwrap_or_else(|e|err(&e,&["Use bind-remote-board --remote-board <exact native path> after adopting a different board. Old desktop model results do not qualify the current snapshot.".into()]));+                r.data["remoteBinding"]=proof;+            }             r.stage_start("models", Some("binary"), None);             let v = br.call("kicad_model_check", &json!({"filePath": rb}));             let d = v.get("data").cloned().unwrap_or(v.clone());
crates/adom-aiflow/src/remote_binding.rsadded+82
@@ -0,0 +1,82 @@+//! Bind a saved native KiCad file to the adopted snapshot, never a stale filename.+use serde_json::{json, Value};+use sha2::{Digest, Sha256};+use std::path::{Path, PathBuf};+pub fn hash(p: &Path) -> Result<String, String> {+    std::fs::read(p)+        .map(|b| format!("{:x}", Sha256::digest(b)))+        .map_err(|e| format!("read {}: {e}", p.display()))+}+pub fn files(local: &Path, remote: &str) -> Result<Vec<(PathBuf, String)>, String> {+    let remote = remote.replace('\\', "/");+    let bytes = remote.as_bytes();+    if !(remote.starts_with('/')+        || (bytes.len() > 3+            && bytes[0].is_ascii_alphabetic()+            && bytes[1] == b':'+            && bytes[2] == b'/'))+        || !remote.ends_with(".kicad_pcb")+    {+        return Err("remote board must be an absolute .kicad_pcb path".into());+    }+    let mut out = vec![(local.to_owned(), remote.clone())];+    for ext in ["kicad_pro", "kicad_dru"] {+        let p = local.with_extension(ext);+        if p.is_file() {+            out.push((+                p,+                Path::new(&remote).with_extension(ext).display().to_string(),+            ));+        }+    }+    Ok(out)+}+pub fn verify(local: &Path, remote: &str, download: &Path) -> Result<Value, String> {+    let mut rows = Vec::new();+    for (source, path) in files(local, remote)? {+        let name = path.rsplit('/').next().ok_or("remote filename missing")?;+        let copy = download.join(name);+        let expected = hash(&source)?;+        let actual = hash(&copy)?;+        if actual != expected {+            return Err(format!(+                "native file differs from adopted snapshot: {path}; bind refused, no gate changed"+            ));+        }+        rows.push(json!({"local":source,"remote":path,"sha256":expected,"download":copy}));+    }+    Ok(+        json!({"board":local,"remoteBoard":remote.replace('\\',"/"),"boardSha256":hash(local)?,"files":rows,"scope":"saved board and required project/rule bytes only; not live-editor, model-render or engineering acceptance"}),+    )+}+#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn paths_are_absolute_and_rules_follow() {+        let p = Path::new("absent.kicad_pcb");+        assert!(files(p, "relative.kicad_pcb").is_err());+        assert!(files(p, "fusion:Board").is_err());+        assert!(files(p, "C:\\pcb\\board.kicad_pcb").is_ok());+        assert!(files(p, "/tmp/board.kicad_pcb").is_ok());+    }+    #[test]+    fn mismatch_missing_rules_and_changed_snapshot_refuse() {+        let d = std::env::temp_dir().join(format!("remote-bind-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join("pull")).unwrap();+        let local = d.join("local.kicad_pcb");+        std::fs::write(&local, "board").unwrap();+        std::fs::write(d.join("pull/native.kicad_pcb"), "board").unwrap();+        assert!(verify(&local, "C:/native.kicad_pcb", &d.join("pull")).is_ok());+        std::fs::write(local.with_extension("kicad_dru"), "strict rules").unwrap();+        assert!(verify(&local, "C:/native.kicad_pcb", &d.join("pull")).is_err());+        std::fs::write(d.join("pull/native.kicad_dru"), "weakened rules").unwrap();+        assert!(verify(&local, "C:/native.kicad_pcb", &d.join("pull")).is_err());+        std::fs::write(d.join("pull/native.kicad_dru"), "strict rules").unwrap();+        assert!(verify(&local, "C:/native.kicad_pcb", &d.join("pull")).is_ok());+        std::fs::write(&local, "new board").unwrap();+        assert!(verify(&local, "C:/native.kicad_pcb", &d.join("pull")).is_err());+        std::fs::remove_dir_all(d).unwrap();+    }+}
docs/native-board-binding.mdadded+14
@@ -0,0 +1,14 @@+# Bind the native board after adoption++Adopting a PCB changes the run snapshot; an old desktop filename must not qualify its models. For KiCad, copy the adopted board and its unchanged `.kicad_pro` and `.kicad_dru` sidecars to the intended desktop path, then run:++```sh+adom-aiflow --run RUN --ai-thread THREAD bind-remote-board --remote-board ABSOLUTE_DESKTOP_PATH.kicad_pcb+adom-aiflow --run RUN --ai-thread THREAD models+```++The bind command downloads the native board and required sidecars to a unique evidence directory and compares SHA-256 hashes with the current adopted files. Missing files or different board/rule bytes refuse without changing the desktop path or accepting any gate. Successful binding records the exact files and updates only the run binding. Adoption invalidates that binding. `models` rechecks the bytes before beginning its native stage.++Binding checks saved files, not the live editor, render fidelity, placement, connectivity, cooling or fabrication acceptance. Verify the exact live editor independently before placement or measurement. Never overwrite an older checkpoint just to make its filename match. Fusion retains its existing native path.++Regression coverage includes invalid paths, missing sidecars, weakened rules and a changed adopted board. Prototype verification on the electronic-load V8 run confirmed the intended desktop board and sidecars, followed by 115-footprint model resolution. Shared source publication and deployed availability are separate.