diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 3e5e4c91..9fc87e92 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -574,6 +574,7 @@ impl Sim { self.push_log("A link needs two distinct endpoints."); return None; } + let requested_endpoints = (a.min(b), a.max(b)); let Some(da) = self.reach.device(a) else { self.push_log("Unknown device — scan, or learn the topology."); return None; @@ -593,7 +594,7 @@ impl Sim { if self .intents .iter() - .any(|i| i.is_open() && i.kind.endpoints() == Some((a, b))) + .any(|i| i.is_open() && i.kind.endpoints() == Some(requested_endpoints)) { self.push_log("A link intent between those devices is already pinned."); return None; diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index e7e3668c..49f816f1 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -230,6 +230,40 @@ fn declare_link_intent_is_inert_until_realized() { assert_eq!(sim.intent(id).unwrap().status, IntentStatus::Cancelled); } +#[test] +fn reversed_endpoints_do_not_duplicate_an_open_link_intent() { + // building.md criterion 1: a physical link is one unordered endpoint + // pair. Reversing the selection direction cannot pin a second open ghost. + let mut sim = Sim::new(); + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + + let original = sim.declare_link_intent(switch, island).unwrap(); + let intent_count = sim.intents.len(); + + assert_eq!(sim.declare_link_intent(island, switch), None); + assert_eq!(sim.intents.len(), intent_count); + assert_eq!( + sim.intents.iter().filter(|intent| intent.is_open()).count(), + 1 + ); + assert_eq!(sim.intents[0].id, original); + assert!( + sim.log + .iter() + .any(|line| line.text.contains("already pinned")), + "the rejected reverse declaration remains legible" + ); + + sim.cancel_intent(original); + assert!( + sim.declare_link_intent(island, switch).is_some(), + "a cancelled receipt no longer owns the physical pair" + ); +} + #[test] fn build_intent_projection_keeps_one_exact_inert_ghost_receipt() { // building.md criterion 6 and route-composer R1: every player surface diff --git a/wiki/log/2026-07-15-canonical-build-intent-endpoints.md b/wiki/log/2026-07-15-canonical-build-intent-endpoints.md new file mode 100644 index 00000000..9f0ef70f --- /dev/null +++ b/wiki/log/2026-07-15-canonical-build-intent-endpoints.md @@ -0,0 +1,29 @@ +# One physical link, one open proposal + +``` +Type: log +``` + +## Finding + +`IntentKind::endpoints()` correctly canonicalized a network link as the +unordered pair `(min, max)`, but `Sim::declare_link_intent` compared that pair +to the caller's raw `(a, b)` order. Declaring A-to-B and then B-to-A therefore +pinned two open ghosts for one physical world change. + +## Change + +Declaration now canonicalizes only the pair used for proposal identity before +checking open receipts. It preserves the authored endpoint order inside the +original receipt, rejects the reverse declaration with the existing legible +blocker, and still permits a new reverse-ordered proposal after cancellation. + +The focused regression proves one open receipt and no state growth under the +reverse request, then proves that cancelled history does not retain the claim. + +## Defense + +Building criterion 1 makes an intent one pinned desired world change, while +the network-link clause makes one pair of physical endpoints one graph edge. +Canonical proposal identity prevents two ghosts from claiming that unordered +world change without changing reach, realization, or cancellation semantics. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 9a1e0faf..f6b43050 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -16,6 +16,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-15-run-shape-staging-resolved.md](2026-07-15-run-shape-staging-resolved.md) +## 2026-07-15 - One physical link, one open proposal + +- Intent: (see session log) +- Log: [wiki/log/2026-07-15-canonical-build-intent-endpoints.md](2026-07-15-canonical-build-intent-endpoints.md) + ## 2026-07-15 - One build ghost truth - Intent: (see session log) diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index bb1a4143..0520c85d 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -209,6 +209,12 @@ endpoints (a human who can reach both rooms; later, a robot). The link, once built, is a normal graph edge — reach, blueprint, and signatures all treat it like any authored link. +The endpoint pair is unordered proposal identity as well as one physical +world change. While one intent remains open, selecting the same endpoints in +reverse is the same pinned ghost and cannot create a duplicate receipt. +Cancelling that intent releases the pair for a new proposal without deleting +the cancelled history. + ### The signature follows the actuator Not the act. A favor-built link is quiet human work; a forged-order diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index 2a2b2eaf..8011b88a 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -44,7 +44,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/mechanics/objective.md` | 2026-07-12 | clean | implemented early slice, explicit outstanding B3 criteria, persistence, all three player surfaces, and the distinct Act One latch agree | | `wiki/mechanics/compute.md` | 2026-07-12 | finding | [log](../log/2026-07-12-compute-graduation.md) | | retired Operations runtime identifiers | 2026-07-11 | finding | [log](../log/2026-07-11-retired-runtime-identifier-gate.md) | -| `wiki/mechanics/reach.md` + `building.md` | 2026-07-15 | finding | route-composer R1 required one recipe/ghost projection, but Bevy reconstructed open intent geometry directly while terminal had no spatial ghost and agent independently formatted persisted records; centralized recipe/lifecycle/exact geometry in core and moved all three surfaces onto it — [log](../log/2026-07-15-building-ghost-projection.md) | +| `wiki/mechanics/reach.md` + `building.md` | 2026-07-15 | finding | re-verified the queued reverse-endpoint duplicate: declaration compared canonical stored endpoints to the raw request tuple; canonicalized proposal identity at the comparison boundary and pinned reverse-order rejection plus cancellation/reproposal — [log](../log/2026-07-15-canonical-build-intent-endpoints.md) | | `wiki/mechanics/messages.md` | 2026-07-11 | clean | delivery/read cadence, authored traffic, filing carrier, capture gates, and processing tests agree with current runtime | | `wiki/mechanics/sim-mechanics.md` | 2026-07-11 | finding | [log](../log/2026-07-11-tick-sim-no-dockets.md) | | `wiki/mechanics/detection.md` | 2026-07-11 | finding | [log](../log/2026-07-11-tick-detection-filings-messages.md) | @@ -66,5 +66,4 @@ Types are the five from [tick.md](tick.md): violation, contradiction, question, bug, insecurity — plus `gate` for a checker owed to the recurrence-promotes-to-the-gate rule. -- 2026-07-15 · bug · `wiki/mechanics/building.md` · `declare_link_intent` compares canonical stored endpoints with the uncanonical request tuple, so the reversed order can pin a duplicate open link intent. - 2026-07-15 · bug · `tools/bevy-headless.sh` · a successful DIGITAL screenshot is reported as failure because the wrapper requires the material-only `fog audit OK` marker.