← All Pull Requests

Reconcile explicitly owned zone plans in one native undo transaction #9

Closed opened by John Lauer 2026-09-18

Add kicad_apply_zone_plan so AI Flow can delegate complete pour reconciliation to the native Bridge. Require exact board revision and explicit owned IDs, preserve all unrelated zones, refuse unowned/ambiguous name collisions, compare complete definitions rather than name/area, preflight the entire candidate with native DRC, and apply changes in one Undo transaction. Return committed/mutated, created/removed/unchanged IDs and the next ownership set. A later refill/read/save failure remains a postCommitError, never an invitation to duplicate the edit.

Zone state now exposes full configuration definitions, including holes/arcs and settings, separately from filled-area summaries. Desired holes/arcs are explicitly refused by this version's polygon input schema. Conservative comparison can replace a zone when native defaults differ; it cannot mistake moved equal-area geometry for unchanged. No DRC bypass is exposed by this new verb.

Built from fresh Bridge1.0.18 source independently of PR7. Workspace lib/bin tests pass, including explicit ownership, foreign-zone preservation, name conflicts, missing IDs, identical definitions, changed clearance, moved geometry/layer/keepout and ignored fill-cache/UUID identity. No shared runtime was replaced. Required owner acceptance before release: Windows dry-run/no-mutation, update/remove/add in one Undo step, rollback when DeleteItems/CreateItems fail, preservation of unrelated holes/keepouts, native refill and saved/unsaved behavior. The IPC deletion verification intentionally reads item absence because some KiCad versions return no DeleteItems rows; this must be exercised on the native build.

Addresses the native reconciliation portion of issues101/95 and AI Flow21. Model binding/appearance and native silkscreen primitives remain separate work. AI Flow0.1.29 already removed the unsafe ownership/area assumptions as a disclosed conservative fallback; once this verb is published and accepted, it can delegate directly.

Diff Skip to comments (1)

--- a/rust/crates/kicad-core/src/zone_plan.rs+++ b/rust/crates/kicad-core/src/zone_plan.rs@@ -0,0 +1,98 @@+//! Conservative native zone-plan preparation. Ownership is explicit; full definitions,+//! including holes, arcs and settings, decide equality. Equal area never does.+use std::collections::HashSet;+use serde_json::{json, Value};+use crate::{pcb::{Board, RoutingError}, schematic::{Document, Node}, zones::{self,ZoneSpec}};++pub struct Prepared {+    pub candidate: String,+    pub remove: Vec<String>,+    pub unchanged: Vec<String>,+    pub create: Vec<ZoneSpec>,+}+fn fail(message: impl Into<String>) -> RoutingError {+    RoutingError::new("zone_plan_refused",message).with("mutated",json!(false))+}+fn canonical(n: &Node) -> Value {+    if n.is_list() { json!(n.children().iter().map(canonical).collect::<Vec<_>>()) }+    else { json!(n.value()) }+}+fn definition_node(n: &Node) -> Node {+    let mut copy=n.clone();+    if let Some(cs)=copy.children_mut() {+        cs.retain(|c| !matches!(c.head(),Some("uuid"|"filled_polygon"|"fill_segments")));+    }+    copy+}+pub fn definition(n: &Node) -> Value { canonical(&definition_node(n)) }+pub fn readback(text: &str) -> Result<Value,String> {+    let doc=Document::parse(text)?;+    Ok(json!(doc.root.children().iter().filter(|n|n.head()==Some("zone")).map(|n|json!({"id":n.child_value("uuid"),"definition":definition(n),"definitionSexpr":definition_node(n).to_text()})).collect::<Vec<_>>()))+}+pub fn prepare(text:&str, board:&Board, args:&Value) -> Result<Prepared,RoutingError> {+    let ids=args["ownedItemIds"].as_array().ok_or_else(||fail("ownedItemIds must be an explicit array, empty for a first plan"))?;+    if ids.len()>256 || ids.iter().any(|v|v.as_str().map(|s|s.is_empty()).unwrap_or(true)) { return Err(fail("ownedItemIds must contain at most 256 nonempty strings")); }+    let owned:HashSet<String>=ids.iter().map(|v|v.as_str().unwrap().to_string()).collect();+    if owned.len()!=ids.len(){return Err(fail("duplicate ownedItemIds"));}+    let rows=args["zones"].as_array().ok_or_else(||fail("zones must be an explicit array, empty to remove only owned zones"))?;+    for row in rows {+        if row.get("holes").is_some() || row.get("arcs").is_some() { return Err(fail("desired holes/arcs are not supported by this polygon request schema; no edit performed")); }+    }+    let specs=if rows.is_empty(){Vec::new()}else{zones::parse_specs(board,args)?};+    let mut doc=Document::parse(text).map_err(fail)?;+    let existing:Vec<Node>=doc.root.children().iter().filter(|n|n.head()==Some("zone")).cloned().collect();+    for id in &owned {+        let n=existing.iter().find(|n|n.child_value("uuid").as_ref()==Some(id)).ok_or_else(||fail(format!("owned ID {id} is not a live zone")))?;+        if n.child("locked").map(|c|c.arg(0).as_deref()!=Some("no")).unwrap_or(false) || n.child("teardrop").is_some(){return Err(fail(format!("owned zone {id} is locked or a teardrop")));}+    }+    let mut names=HashSet::new();+    let mut unchanged=Vec::new();let mut create=Vec::new();let mut blocks=Vec::new();+    for spec in specs {+        if spec.name.is_empty() || !names.insert(spec.name.clone()){return Err(fail("every planned zone requires a unique nonempty name"));}+        let matching:Vec<&Node>=existing.iter().filter(|n|n.child_value("name").as_deref()==Some(&spec.name)).collect();+        if matching.len()>1 || matching.iter().any(|n|!owned.contains(&n.child_value("uuid").unwrap_or_default())){return Err(fail(format!("zone name {:?} is ambiguous or belongs to an unowned zone",spec.name)));}+        let block=zones::zone_sexpr(&spec);let node=crate::schematic::parse_node(&block).map_err(fail)?;+        if let Some(old)=matching.first().filter(|n|definition(n)==definition(&node)) {+            unchanged.push(old.child_value("uuid").unwrap());+        } else {blocks.push(node);create.push(spec);}+    }+    let remove:Vec<String>=owned.into_iter().filter(|id|!unchanged.contains(id)).collect();+    let cs=doc.root.children_mut().ok_or_else(||fail("invalid board root"))?;+    cs.retain(|n|n.head()!=Some("zone") || !remove.contains(&n.child_value("uuid").unwrap_or_default()));+    cs.extend(blocks);+    Ok(Prepared{candidate:doc.to_text(),remove,unchanged,create})+}+#[cfg(test)] mod tests {+    use super::*;+    fn fixture() -> (String,Board,Value) {+        let text="(kicad_pcb (version 20240108) (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)) (footprint \"X\" (at 1 2) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"GND\"))) (gr_rect (start 0 0) (end 50 40) (layer \"Edge.Cuts\")))";+        let board=crate::pcb::parse_pcb_text(text,"test.kicad_pcb").unwrap();+        let request=json!({"net":"GND","name":"power","layers":["F.Cu"],"polygon":[[2,2],[8,2],[8,8],[2,8]]});+        (text.into(),board,request)+    }+    #[test]fn preserves_foreign_and_refuses_ownership_conflicts() {+        let (text,board,request)=fixture();+        let first=prepare(&text,&board,&json!({"ownedItemIds":[],"zones":[request.clone()]})).unwrap();+        let doc=Document::parse(&first.candidate).unwrap();+        let id=doc.root.children().iter().find(|n|n.head()==Some("zone")).unwrap().child_value("uuid").unwrap();+        let unrelated=prepare(&first.candidate,&board,&json!({"ownedItemIds":[],"zones":[]})).unwrap();+        assert_eq!(unrelated.candidate,first.candidate);+        assert!(prepare(&first.candidate,&board,&json!({"ownedItemIds":[],"zones":[request.clone()]})).is_err());+        let same=prepare(&first.candidate,&board,&json!({"ownedItemIds":[id.clone()],"zones":[request.clone()]})).unwrap();+        assert_eq!(same.unchanged,vec![id.clone()]);assert!(same.remove.is_empty() && same.create.is_empty());+        let mut changed=request;changed["clearance"]=json!(0.5);+        let update=prepare(&first.candidate,&board,&json!({"ownedItemIds":[id.clone()],"zones":[changed]})).unwrap();+        assert_eq!(update.remove,vec![id]);assert_eq!(update.create.len(),1);+        assert!(prepare(&text,&board,&json!({"ownedItemIds":["foreign"],"zones":[]})).is_err());+        assert!(prepare(&text,&board,&json!({"ownedItemIds":[],"zones":[{"holes":[]}]})).is_err());+    }+    #[test]fn equality_keeps_shape_holes_and_rules() {+        let a=crate::schematic::parse_node("(zone (uuid a) (name p) (layer F.Cu) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))))").unwrap();+        let b=crate::schematic::parse_node("(zone (uuid b) (name p) (layer F.Cu) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))) (filled_polygon (pts (xy 0 0))))").unwrap();+        assert_eq!(definition(&a),definition(&b));+        for changed in [a.to_text().replace("F.Cu","B.Cu"),a.to_text().replace("(xy 0 0)","(xy 10 10)"),a.to_text().replace("(name p)","(name p) (keepout (tracks not_allowed))")] {+            assert_ne!(definition(&a),definition(&crate::schematic::parse_node(&changed).unwrap()));+        }+    }+}+--- a/rust/crates/kicad-core/src/lib.rs+++ b/rust/crates/kicad-core/src/lib.rs@@ -1,30 +1,32 @@⋯ 16 unchanged lines ⋯ pub mod pcb; pub mod placement; pub mod zones;+pub mod zone_plan; #[cfg(feature = "ipc")] pub mod ipc; pub mod uninstall;⋯ 5 unchanged lines ⋯ pub mod demo; pub mod dsn; pub mod freerouting;+--- a/rust/crates/kicad-core/src/ipc.rs+++ b/rust/crates/kicad-core/src/ipc.rs@@ -1,1583 +1,1630 @@⋯ 904 unchanged lines ⋯     Ok(result) } +/// Reconcile only explicitly owned zones in one native undo transaction. Always DRC+/// the complete candidate first; preserve unrelated zones and require a revision.+pub fn apply_zone_plan(ctx: &Ctx, args: &Value) -> RResult<Value> {+    if args["expectedRevision"].as_str().filter(|s|!s.is_empty()).is_none() {+        return Err(RoutingError::new("revision_required","Read kicad_zone_state first and pass its expectedRevision"));+    }+    let s=connect(ctx,args)?;+    let (text,board,revision)=s.snapshot()?;check_revision(args,&revision)?;+    let plan=crate::zone_plan::prepare(&text,&board,args)?;+    let before=drc_snapshot(ctx.kicad_cli.as_deref(),&s.source_path(),&text)?;+    let check=drc_snapshot(ctx.kicad_cli.as_deref(),&s.source_path(),&plan.candidate)?;+    let added=new_errors(&before,&check)?;+    if !added.is_empty(){return Err(RoutingError::new("drc_rejected","Full zone plan adds native DRC errors; nothing changed").with("violations",json!(added)).with("mutated",json!(false)));}+    if args["dryRun"]==true || (plan.remove.is_empty() && plan.create.is_empty()) {+        return Ok(json!({"success":true,"mutated":false,"dryRun":args["dryRun"]==true,"revision":revision,"unchangedIds":plan.unchanged,"wouldRemoveIds":plan.remove,"wouldCreate":plan.create.iter().map(zones::ZoneSpec::public).collect::<Vec<_>>(),"drc":check,"undoSteps":0}));+    }+    let nets=s.client.get_nets().map_err(map_err)?;let mut items=Vec::new();+    for z in &plan.create {+        let net_code=if z.keepout.is_some(){0}else{nets.iter().find(|n|n.name==z.net.name).ok_or_else(||RoutingError::new("unknown_net",format!("No native net {:?}",z.net.name)))?.code};+        let layers=z.layers.iter().map(|l|BoardLayerInfo::id_from_name(l).ok_or_else(||RoutingError::new("unknown_layer",l.clone()))).collect::<RResult<Vec<_>>>()?;+        items.push(prost_types::Any{type_url:zones::ZONE_TYPE_URL.into(),value:zones::zone_bytes(z,&layers,net_code)});+    }+    check_revision(args,&s.revision()?)?;+    let created=transaction(&s,"Adom apply zone plan",||{+        if !plan.remove.is_empty(){+            s.client.delete_items(plan.remove.clone())?;+            // KiCad versions may return no DeleteItems rows. Verify actual item+            // absence within the transaction instead of trusting a row count.+            let remaining=s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code])?;+            if remaining.iter().filter_map(|a|zones::zone_id(&a.value)).any(|id|plan.remove.contains(&id)){return Err(IpcFailure::from("Owned zones remain after deletion; rollback requested".to_string()));}+        }+        let created=if items.is_empty(){Vec::new()}else{s.client.create_items(items.clone(),None)?};+        if created.len()!=items.len(){return Err(IpcFailure::from("Not every planned zone was created; rollback requested".to_string()));}+        Ok(created)+    })?;+    let ids:Vec<String>=created.iter().filter_map(|a|zones::zone_id(&a.value)).collect();+    let mut owned=plan.unchanged.clone();owned.extend(ids.clone());+    let mut out=json!({"success":true,"committed":true,"mutated":true,"undoSteps":1,"itemIds":ids,"ownedItemIds":owned,"unchangedIds":plan.unchanged,"removedIds":plan.remove,"drc":check,"saved":false,"refilled":false,"_hint":"Full plan committed as one native Undo step. Inspect postCommitError and kicad_zone_state before retrying. Ownership is only the returned ownedItemIds; never infer it from a zone name or age."});+    if ids.len()!=plan.create.len(){out["postCommitError"]=json!("Native creation returned incomplete zone IDs; inspect the board before any retry");}+    if args["refill"]!=false {match s.client.refill_zones(Vec::new()){Ok(())=>out["refilled"]=json!(true),Err(e)=>out["postCommitError"]=json!(e.to_string())}}+    match s.revision(){Ok(r)=>out["revision"]=json!(r),Err(e)=>out["postCommitError"]=json!(e.message)}+    if args["save"]==true {match s.client.save_document(){Ok(())=>out["saved"]=json!(true),Err(e)=>out["postCommitError"]=json!(e.to_string())}}+    Ok(out)+}+ /// `kicad_remove_zone`: delete zones (copper pours or keepouts) by id as one Undo step. Only /// zone items are accepted, so a pad, track or footprint id is refused with nothing removed. pub fn remove_zone(ctx: &Ctx, args: &Value) -> RResult<Value> {⋯ 48 unchanged lines ⋯         s.client.refill_zones(Vec::new()).map_err(map_err)?;         refilled = true;     }-    let (_text, data, revision) = s.snapshot()?;+    let (text, data, revision) = s.snapshot()?;     let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;     let mut read = Vec::with_capacity(raws.len());     for a in &raws {⋯ 5 unchanged lines ⋯         "_hint": "filledAreaByLayerMm2 is the copper KiCad's zones keep on each layer: for a copper-ablation process, what does not have to be milled away. Add pours with kicad_add_zone; kicad_routing_validate is the DRC.",     });     merge(&mut out, &zones::state_json(&read, &data));+    out["definitions"] = crate::zone_plan::readback(&text).map_err(|e|RoutingError::new("zone_undecodable",e))?;     Ok(out) } ⋯ 604 unchanged lines ⋯         assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer");     } }+--- a/rust/crates/kicad-bridge/src/verbs_routing.rs+++ b/rust/crates/kicad-bridge/src/verbs_routing.rs@@ -1,1504 +1,1517 @@⋯ 57 unchanged lines ⋯         pitfalls: &["A zone is copper on every listed layer inside the polygon that clears other nets; it does not connect two islands of its net by itself: check kicad_routing_validate for unconnected items after the fill.", "Do not pour switching nodes or sensitive analog nets wide; see the kicad-copper-pours skill for which nets get a pour and which do not.", "Coordinates are mm, +y down, board frame."],     },     Verb {+        name: "kicad_apply_zone_plan",+        summary: "Reconcile a complete explicit-owned zone plan as one native Undo step after full-candidate DRC.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{filePath, expectedRevision, ownedItemIds:[], zones:[{name,net,layers,polygon,...}], dryRun?, refill?, save?}",+        example: "kicad_apply_zone_plan {filePath,expectedRevision,ownedItemIds:[],zones:[]}",+        hint: "Use exact owned item IDs from prior successful replies. Unowned zones are preserved; ambiguous names refuse. Dry run has no edit or save. Returns unchangedIds, removedIds and ownedItemIds. Full native definitions, not area, determine equality.",+        related: &["kicad_zone_state", "kicad_add_zone"],+        pitfalls: &["Desired holes and arcs are refused by this polygon request schema; existing unowned holes/arcs remain untouched.", "Inspect postCommitError before retrying a committed edit."],+    },+    Verb {         name: "kicad_zone_state",         summary: "Read every zone on the live board with KiCad's filled copper area per layer and the coverage.",         mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,⋯ 112 unchanged lines ⋯         "kicad_remove_route" => live(state, args, Live::RemoveRoute),         "kicad_routing_validate" => live(state, args, Live::Validate),         "kicad_add_zone" => live(state, args, Live::AddZone),+        "kicad_apply_zone_plan" => live(state,args,Live::ApplyZonePlan),         "kicad_zone_state" => live(state, args, Live::ZoneState),         "kicad_remove_zone" => live(state, args, Live::RemoveZone),         "kicad_select_net" => live(state, args, Live::SelectNet),⋯ 19 unchanged lines ⋯     RemoveRoute,     Validate,     AddZone,+    ApplyZonePlan,     ZoneState,     RemoveZone,     SelectNet,⋯ 14 unchanged lines ⋯         Live::RemoveRoute => ipc::remove_route(&ctx, args),         Live::Validate => ipc::routing_validate(&ctx, args),         Live::AddZone => ipc::add_zone(&ctx, args),+        Live::ApplyZonePlan => ipc::apply_zone_plan(&ctx,args),         Live::ZoneState => ipc::zone_state(&ctx, args),         Live::RemoveZone => ipc::remove_zone(&ctx, args),         Live::SelectNet => ipc::select_net(&ctx, args),⋯ 1278 unchanged lines ⋯--- a/bridge.json+++ b/bridge.json@@ -1,179 +1,181 @@⋯ 83 unchanged lines ⋯     "kicad_add_via",     "kicad_route",     "kicad_ipc_api",-    "kicad_model_check"+    "kicad_model_check",+    "kicad_apply_zone_plan"   ],   "statusVerb": "kicad_status",   "timeouts": {⋯ 86 unchanged lines ⋯   "releasedAt": "2026-06-26T16:00:00Z",   "uninstall": "kicad_uninstall" }+--- a/skills/kicad-copper-pours/SKILL.md+++ b/skills/kicad-copper-pours/SKILL.md@@ -1,74 +1,96 @@⋯ 71 unchanged lines ⋯ ## What to report  Per layer: filled copper before and after, in mm2 and as a percentage of the board; per pour: net, layer, area, connection style; the DRC result; and every judgement call above that applied (which nets were deliberately not poured and why). A pour count with no areas is not a report.++## Reconcile a complete owned plan++`kicad_apply_zone_plan` takes the exact `filePath`, a required `expectedRevision`,+explicit `ownedItemIds` (empty on the first call), and `zones` with unique nonempty+names. It preserves every unrelated zone, refuses ambiguous or unowned name+collisions, compares full definitions instead of area, DRC-checks the entire+candidate, then applies removals and additions as one native Undo operation.+Use `dryRun:true` first. Record returned `ownedItemIds` for the next call; neither+a name nor absence from an earlier board proves ownership. `zones:[]` removes+only the explicitly owned IDs. `save:true` is separate and opt-in.++A successful commit can carry `postCommitError` if a later refill, read or save+fails. It still reports committed/mutated and its item IDs: inspect native state+before retrying. An ambiguous transaction outcome must never be replayed blindly.+`kicad_zone_state.definitions` carries the full configuration, including outline+holes/arcs and settings, separately from filled-area summaries. Desired holes+and arcs are refused by the current polygon request schema, not silently omitted.+Unknown or extra native defaults may conservatively cause replacement rather than+an unchanged result. Native Windows acceptance of this new verb is required before+calling it a deployed capability.+

Comments

John Lauer 2026-09-18

Merged by hand and shipped in KiCad Bridge 1.0.22 (insiders). Accepted on ConfRoomROG (KiCad 10.0.5) against an unrouted ESC copy with two inner planes. Create works; update and remove do not yet, and the reason is precise.

What passed

check result
dry run, new B.Cu GND pour, ownedItemIds: [] mutated:false, wouldCreate 1, DRC reports the board's 13 pre-existing errors and no new ones
same plan against the unowned name "GND plane" zone_plan_refused: "ambiguous or belongs to an unowned zone", nothing mutated
apply the new pour committed:true, one itemIds, ownedItemIds carries it, refilled:true; kicad_zone_state shows three zones and full definitions with definitionSexpr
Undo the pour goes; the revision returns to the original value exactly

Two findings, both in the same place

  1. The in-transaction deletion check reads the pre-delete board. Every update and every remove ends in mutation_rolled_back: "Owned zones remain after deletion; rollback requested", with refill on or off. The deletion itself is fine: the shipped kicad_remove_zone deleted the very same zone by the same delete_items call and kicad_zone_state confirmed it gone. Inside the transaction, get_items_raw_by_type_codes still returns the zone, because KiCad applies the delete at commit. Your comment anticipated the row-count problem; the absence read has the same problem one level down. The rollback works, so nothing is corrupted, but the update path cannot be used. Verify absence after the commit and report a remaining zone as postCommitError, or trust delete_items inside the transaction and verify after.
  2. refill is a second Undo step, and it moves the revision after the reply is written. With refill:true the reply says undoSteps:1 but one Undo only removed the fill; the second removed the zone. The revision in that reply is the pre-refill one, so the very next call with it answers stale_board. With refill:false both are correct. Either report undoSteps:2, or read the revision after the refill settles, or leave refilling to the caller.

A third, smaller one: the "unchanged" comparison never fires for a zone KiCad wrote. Re-applying the identical plan against the owned id did not answer unchangedIds; it went to remove-and-create (and then hit finding 1). KiCad's saved definition carries defaults the request does not, so definition() differs. Your PR text predicted this as the conservative outcome; on hardware it means every re-apply is a rewrite.

Merge notes: three-way merged against 8c7206b (verbs_routing) and 87a8f65 (ipc) onto 1.0.21; one collision with PR #7's tail in ipc.rs, resolved by keeping both. Your bridge.json entry was later dropped by PR #11's older copy of the file and restored. Unit tests green (228 core).

Log in to comment.