← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.5+0.9.6
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.5"+version = "0.9.6" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.5"+version = "0.9.6" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+14−1
@@ -458,7 +458,20 @@ not.  ## Roadmap -- v0.9.5 (this): **the gates say why they failed.** 0.9.4 was LIVE-PROVEN on+- v0.9.6 (this): **priming fires again.** 0.9.5 was measured LIVE on 2026-10-01+  (launch gate 16/16, `swx_docs` refused during startup, sweep PASS, drain 5/5)+  and regressed one thing: a document count read during startup recorded a+  never-driven session as having held a document, and the history guard then+  refused to prime — the quit was declined twice. The session history is now+  ADVISORY (priming triggers on the declined, empty, dialog-free, authorized quit+  itself), and it records what each poll saw: never from a count read while+  starting, and only from a count that persists across two polls or from the+  bridge's own create/open. The quit gate judges dialogs by the bridge's verdict+  (a notification no longer refuses its baseline); every gated verb's reply+  carries the readiness its gate evaluated; the performance log is read from its+  per-start header, and the on-demand rule keys on the platform add-in's DLL+  (`SWK3DCompassAddin.dll`). 473 tests.+- v0.9.5: **the gates say why they failed.** 0.9.4 was LIVE-PROVEN on   2026-10-01: the notification rule (a quit in 5.1 s with the 3DEXPERIENCE notice   on screen), priming (declined at 130 s, primed, quit in 3.4 s), and the full   cycle (launch, ownership sweep, drain 5/5, quit gate exit 0). The age data
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.5",+  "version": "0.9.6",   "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",
docs/ECOSYSTEM-TABLE.md+1−1
@@ -113,7 +113,7 @@ templates**, alisamsam **22**.  | 48 | **A title collision between SAVED documents is impossible here — measured, not assumed** | ✗ | **✓ MEASURED LIVE 2026-09-17** — `tools/drain-gate.py --fixture new` copied a bridge-created document's own file to a second directory under the same basename and called `swx_open` on it. SOLIDWORKS REFUSED: `errors = 65536` (`swFileWithSameTitleAlreadyOpen`). So the drain's title-collision guard — which refuses to close any document whose title another open document shares, and names both paths — can only ever fire for PATHLESS documents, which is exactly the state identity-at-birth removes. The guard stays (a document the bridge did not create can still be pathless, and an older bridge's leftovers are), and the measurement is recorded next to it in `com.rs` so nobody "simplifies" it on the assumption that saved collisions are the common case. No MCP server measures this at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 51 | **Dialogs judged by MEASURED MODALITY, not by existence** (a notification never blocks; a modal always does) | ✗ | **✓ LIVE-PROVEN 2026-10-01 on 0.9.4** — with the 3DEXPERIENCE "Connected" notice on screen, `swx_stop` quit in 5.1 s and reported it as a notification with its evidence. **0.9.4, from a blocker MEASURED 2026-10-01**: the 3DEXPERIENCE "Connected to …" notice — a title-less `#32770` tool window whose owner frame stayed ENABLED for 95 s, with no Button-class control — blocked every quit, sweep and import for minutes. Now a window blocks when its kind is known-blocking, its owner frame is disabled, or it has a Button-class control; it is a `notification` (reported with owner, ownerEnabled, styles, rect and child controls; never answered) only when all of that was measured; unread evidence fails closed. Template placeholder captions are flagged, never presented as the window's words. **0.9.5 (adversary pass 43): the window's WORDS count — a save/recovery/import kind in its text blocks whatever the evidence, two or more button-like captions in any control class block, and measured notification evidence overrides a licence-type title alone.** No MCP server looks at a dialog at all | ✗ | ✗ | ✗ | ✗ | ✗ |-| 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; every verb that reaches SOLIDWORKS refuses `host_app_starting` with `retryAfterSeconds` until then — **0.9.5: gated by default with a pinned 16-verb exemption list, the performance log bound to this session's process creation time (an earlier session's log is never "complete"), COM alone never enough on a machine that writes markers, an on-demand add-in told from a start by quiet + which add-in, the grace unlocking reads only, and the window-message quit rungs ungated under userAuthorized**; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; every verb that reaches SOLIDWORKS refuses `host_app_starting` with `retryAfterSeconds` until then — **0.9.5: gated by default with a pinned 16-verb exemption list, the performance log bound to this session's process creation time (an earlier session's log is never "complete"), COM alone never enough on a machine that writes markers, an on-demand add-in told from a start by quiet + which add-in, the grace unlocking reads only, and the window-message quit rungs ungated under userAuthorized; LIVE 2026-10-01 a document verb was refused during startup. 0.9.6: every gated reply carries the readiness its gate evaluated, the log is read from its per-start header, and the on-demand rule keys on the platform DLL SWK3DCompassAddin.dll**; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 47 | **Interconnect-aware import: refuses honestly, with the reason and the setting** | ✗ | **✓ MEASURED + shipped 0.8.2** — on SOLIDWORKS Connected 2026 with `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic neutral-file import route at all: LoadFile4 returns nothing (`errors=1`) both with `Nothing` and with a real `GetImportFileData` object, LoadFile2 returns false, OpenDoc6 answers `swFileRequiresRepairError` (2097152) across four option values and `swInvalidFileTypeError` (1024) for docType 0. So `swx_import` makes ONE LoadFile4 attempt (a future edition that accepts it simply works) and then fails with `interconnect_blocks_import`, naming the exact setting — Tools > Options > System Options > Import > Enable 3D Interconnect — the measurement date, and the fact that **this bridge does not change user settings, not even temporarily**. No MCP server reads that toggle at all: they report a translator failure, which sends the reader hunting a missing translator that is not the problem | ✗ | ✗ | ✗ | ✗ | ✗ | | 46 | **3D Interconnect is understood, obeyed and reported** (the user's own setting decides how a neutral file comes in; the bridge reads it and never writes it) | ✗ | **✓ LIVE-MEASURED 2026-09-17, routed in 0.8.0** — `GetUserPreferenceToggle(691)` = TRUE on the install, so a STEP is not translated at all: `LoadFile4` executes and returns nothing with `errors=1`, and SOLIDWORKS instead OPENS the file with `OpenDoc6(path, swDocPART, …)` as a part holding a LINKED 3D Interconnect feature. `swx_import` reads the toggle, routes accordingly, and reports `interconnect` / `linked` / `sourcePath` so a caller knows the part is not self-contained. The preference is NEVER written — not even flip-and-restore, because a crash between the halves would leave the user's CAD configured differently than they left it — and `dissolve:true` is REFUSED until the link-breaking call is measured on this edition. Import-diagnostics auto-run (toggle 291) is also TRUE, so the modal dialog it raises is classified, reported as `stoppedByDialog`, and never answered. Toggle ids read from the installed `SolidWorks.Interop.swconst.dll` | ✗ | ✗ | ✗ | ✗ | ✗ |
docs/LAUNCH-RESILIENCE.md+16
@@ -1103,6 +1103,22 @@ run should capture: a `LOAD_ADDIN` line verbatim, whether a start writes a header line before its first `LOAD_ADDIN`, and an on-demand Toolbox load's lines. +**Measured live on 0.9.5 (2026-10-01), and what 0.9.6 does with it.** Launch gate+PASSED (16/16); `swx_docs` was refused `host_app_starting` during startup;+ownership sweep PASS; drain gate 5/5. The performance log:++| | Measured | 0.9.6 |+|---|---|---|+| The file is REWRITTEN at every start | 1271 lines after the crash session, then 107 and 124 on the next starts; it begins `; SolidWorks generated log file - do not edit.` / `<VERSION> 6</VERSION>` / `<PROC_NUM>` / `<PROC_ARCH>` / `<OS>` | Only the text after the last header is read — residual (1) above is CLOSED: no earlier session's marker can be in it |+| mtime vs process start | 13:34:49 vs 13:34:24 | the mtime binding holds |+| A clean start writes a `LOAD_ADDIN` AFTER the marker | `<3DXINITERROR>0` at line 106, a `LOAD_ADDIN` at 107 | Covered by the quiet-window rule: such a start reads READY once the log has been quiet for 60 s — a known cost of up to ~60 s on every start |+| Three add-ins carry "3DEXPERIENCE" | `SaveFolderTo3DEXPERIENCEAddin2.DLL` "Batch Save to 3DEXPERIENCE", `TaskPaneCacheExplorerAddin.DLL` "3DEXPERIENCE files on This PC", `SWK3DCompassAddin.dll` "3DEXPERIENCE for WORKS" | The on-demand rule keys on the platform DLL `SWK3DCompassAddin.dll`, never the substring — residual (2) closed |++**G3 (0.9.6):** every gated verb's reply — success or refusal — carries+`readiness: {ready, state, basedOn, readsAllowed, evaluatedAt:"gate"}` as the+gate evaluated it (`gatePassedFor:"windowMessageRungs"` on a quit let through+during startup), so a live run can prove no COM verb ran while starting.+ ## Live proof 4 — 0.9.3 on Drew's machine (2026-10-01)  | | Measured |
docs/QUIT-INVESTIGATION.md+34
@@ -2026,3 +2026,37 @@ measured, its owner's enablement was not.   waives it), so ExitApp past a missed modeless question loses the question,   not a document — and with unsaved work SOLIDWORKS raises its own MODAL save   prompt, which disables the frame and is caught by the post-rung census.++### 5.20 LIVE 2026-10-01 on 0.9.5 — priming regressed; 0.9.6 makes the history advisory++* **Measured.** Session pid 29108, launched by the launch gate, never driven, no+  document ever opened by anyone: `swx_stop` rung (a) DECLINED (30.7 s),+  `rungsTried` ONE entry, `primed:false`, `host_app_stop_declined` — twice.+  `swx_status.solidworks.sessionHistory` said `everHadDocument:true`, "a document+  was seen open in this session" — FALSE. 0.9.5's readiness poll had recorded a+  non-zero `GetDocumentCount` read while SOLIDWORKS was starting (it may hold a+  hidden document then, or the count is read before it settles), and the guard+  "a session that has held a document is never primed" (§5.18) blocked the+  prime. The coordinator primed by hand (new_document → reset_workspace →+  swx_stop quit in 3.5 s).+* **0.9.6: the history is ADVISORY.** Priming triggers on the measured+  condition itself — rung (a) declined, the session empty (count 0, re-checked+  after the prime too), no BLOCKING dialog, `userAuthorized` — once per+  `swx_stop` call. `sessionHistory` is reported (`advisory:true`) and read by no+  decision. Pinned: `a_session_that_has_held_a_document_is_still_primed_when_empty`+  (restoring the guard fails it).+* **…and the false `true` is fixed.** Every poll records WHAT it saw (count,+  time, source) under `sessionHistory.observations`. A count read while the+  session is starting is never recorded as evidence; after the start, a count+  > 0 must persist across two distinct polls (≥ 1 s apart, no zero between) to+  set `everHadDocument`; the bridge's own create/open sets it directly. Pinned+  by `a_phantom_document_count_never_records_history_and_priming_still_fires`+  (a count of 1 throughout startup, then a count of 1 on one poll).+* **The quit gate (G2).** `tools/quit-gate.py` refused its own baseline on the+  3DEXPERIENCE "Connected" NOTIFICATION (title '') because it counted every+  visible `#32770` as a dialog. It now takes the BRIDGE's verdict —+  `swx_window_info.dialogCensus` (`blocking` + `notifications`, judged by+  `quit::judge_window`, taken without COM) — lists notifications, refuses on a+  blocking dialog (the only case that tells the reader to hand a screenshot to+  the user), and refuses on an unreadable census without blaming the user's+  screen. Its `--self-test` pins all four verdicts.
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.5",+  "version": "0.9.6",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
references/closing-and-recovery.md+2−1
@@ -110,7 +110,8 @@ Read this before closing any document, quitting SOLIDWORKS, draining leaked impo   opened and closed. Under `userAuthorized`, on an empty session with no dialog,   `swx_stop` creates and closes one bridge-owned scratch part and asks again; the   second attempt in `rungsTried` says `primed:true`. Nothing is ever primed while-  a document is open.+  a document is open. `swx_status.solidworks.sessionHistory` is ADVISORY — it+  predicts, it never decides; the prime fires on the declined empty quit itself. - **A NOTIFICATION does not stop anything.** A window is judged by measured   modality and by its words: it blocks when its title or text names a save,   recovery or import prompt, its owner frame is disabled, or it offers a choice
src/comhost/mock.rs+23−1
@@ -3091,7 +3091,29 @@ impl MockBackend {                     // The poll that records a session as having held a document                     // (session_history), exactly as com.rs reports it.                     "documentCount": if self.running && !fault("mock-doccount-none") {-                        json!(self.docs.len())+                        // A PHANTOM COUNT — measured live 2026-10-01 (pid 29108): a+                        // session nobody ever opened a document in was recorded as+                        // having held one. Two shapes: a count of 1 while the start+                        // is incomplete, and a count of 1 on ONE read only.+                        let phantom_starting =+                            fault("mock-phantom-doc-while-starting") && fault("mock-startup-incomplete");+                        // `mock-phantom-doc-once` may hold a number: that many reads+                        // (default 1). Deleting `.used` re-arms it.+                        let root = crate::scratch::root();+                        let once = root.join("mock-phantom-doc-once.used");+                        let reads: u64 = std::fs::read_to_string(root.join("mock-phantom-doc-once"))+                            .ok()+                            .and_then(|t| t.trim().parse().ok())+                            .unwrap_or(1);+                        let used: u64 = std::fs::read_to_string(&once)+                            .ok()+                            .and_then(|t| t.trim().parse().ok())+                            .unwrap_or(0);+                        let phantom_once = fault("mock-phantom-doc-once") && used < reads;+                        if phantom_once {+                            let _ = std::fs::write(&once, (used + 1).to_string());+                        }+                        json!(self.docs.len() + usize::from(phantom_starting || phantom_once))                     } else {                         Value::Null                     },
src/perflog.rs+29−8
@@ -27,6 +27,11 @@ use std::path::PathBuf; /// and the file grows for the life of the install. const TAIL_BYTES: u64 = 2 * 1024 * 1024; +/// The first line of the log, written at every start (measured 2026-10-01).+const LOG_HEADER: &str = "; SolidWorks generated log file";+/// The 3DEXPERIENCE platform add-in's DLL, lower-case (measured 2026-10-01).+const PLATFORM_ADDIN_DLL: &str = "swk3dcompassaddin.dll";+ const LOAD_MARKERS: &[&str] = &["<3DXLOAD_TIME>", "<3DXINITERROR>"]; const CRASH_MARKERS: &[&str] = &["<SWEXH>", "<UNHANDLED>", "<SWERR_REPORT>", "<RUNTIME>"]; @@ -116,6 +121,17 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) {     let Some(t) = tail() else {         return (LogVerdict::Unavailable, format!("the performance log {} could not be read", path.display()));     };+    // THE FILE IS REWRITTEN AT EVERY START, beginning with a header (measured+    // 2026-10-01 on 0.9.5: 1271 lines after the crash session, then 107 and 124+    // on the next starts; first line `; SolidWorks generated log file - do not+    // edit.`). Only what follows the last header belongs to the running start.+    if LOAD_MARKERS.iter().any(|m| t.contains(m)) {+        remember_markers_seen();+    }+    let t: &str = match t.rfind(LOG_HEADER) {+        Some(h) => &t[h..],+        None => &t,+    };     let last_marker = LOAD_MARKERS.iter().filter_map(|m| t.rfind(m).map(|i| (i, *m))).max();     // DOES THIS MACHINE'S SOLIDWORKS WRITE MARKERS AT ALL? Once a marker has been     // seen in this log it is remembered (data root), so a tail that has rolled@@ -169,18 +185,23 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) {     match last_addin {         Some(a) if a > m => {             let quiet = mtime.map(|mt| crate::launchos::epoch_secs().saturating_sub(mt)).unwrap_or(0);-            // A START loads the 3DEXPERIENCE add-in — it is what writes the marker-            // — so a LOAD_ADDIN for it after the last marker is a start in-            // progress, however quiet the log has gone (wedged behind a dialog).+            // A START loads the 3DEXPERIENCE PLATFORM add-in — it is what writes+            // the marker — so a LOAD_ADDIN for it after the last marker is a start+            // in progress, however quiet the log has gone (wedged behind a+            // dialog). Keyed on the DLL NAME (measured 2026-10-01): three add-ins+            // carry "3DEXPERIENCE" in their names — SaveFolderTo3DEXPERIENCEAddin2+            // ("Batch Save to 3DEXPERIENCE"), TaskPaneCacheExplorerAddin+            // ("3DEXPERIENCE files on This PC") and SWK3DCompassAddin+            // ("3DEXPERIENCE for WORKS"); only the last is the platform.             let after = &t[m..];-            let start_block = after-                .lines()-                .any(|l| l.contains("LOAD_ADDIN") && l.to_ascii_uppercase().contains("3DEXPERIENCE"));+            let start_block = after.lines().any(|l| {+                l.contains("LOAD_ADDIN") && l.to_ascii_lowercase().contains(PLATFORM_ADDIN_DLL)+            });             if quiet >= ON_DEMAND_QUIET_SECS && !start_block {                 (                     LogVerdict::OnDemandAfterComplete,                     format!(-                        "a {name} marker, then LOAD_ADDIN line(s) not naming the 3DEXPERIENCE add-in, \+                        "a {name} marker, then LOAD_ADDIN line(s) not naming the platform add-in (SWK3DCompassAddin.dll), \                          then no log activity for {quiet}s (>= {ON_DEMAND_QUIET_SECS}s): an add-in \                          loaded on demand after the start completed — ready only if COM also says the \                          start completed"@@ -192,7 +213,7 @@ pub fn startup_complete(session_start: Option<u64>) -> (LogVerdict, String) {                     format!(                         "add-ins are still loading: the last LOAD_ADDIN is newer than the last {name}{}, \                          and the log was written {quiet}s ago",-                        if start_block { " and names the 3DEXPERIENCE add-in (a start, not an on-demand load)" } else { "" }+                        if start_block { " and names the platform add-in SWK3DCompassAddin.dll (a start, not an on-demand load)" } else { "" }                     ),                 )             }
src/readiness.rs+51−10
@@ -96,15 +96,6 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value {                 .and_then(|ps| ps.iter().find(|x| x.pid == p).and_then(|x| x.created_at))         })         .or_else(|| state.get("processCreatedAt").and_then(|v| v.as_u64()));-    // ANY POLL THAT SEES A DOCUMENT RECORDS IT (adversary pass 42, S5).-    if let (Some(p), Some(n)) = (-        pid.and_then(|p| u32::try_from(p).ok()),-        state.get("documentCount").and_then(|v| v.as_u64()),-    ) {-        if n > 0 {-            crate::session_history::mark(p, created);-        }-    }     let (log, log_why) = crate::perflog::startup_complete(created);     use crate::perflog::LogVerdict as L;     let mut guard = SESSIONS.lock().unwrap_or_else(|e| e.into_inner());@@ -141,6 +132,20 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value {     if ready && pid.is_some() {         seen.ready = true;     }+    drop(guard);+    // WHAT THE POLL SAW, recorded — but only on a session verified READY. Live+    // 2026-10-01 (pid 29108): a count read during startup recorded a session no+    // one had opened a document in as having held one. A count while starting is+    // never evidence; after the start, a count > 0 must persist across two polls+    // (session_history::observe) to count.+    if let (Some(p), Some(n)) = (+        pid.and_then(|p| u32::try_from(p).ok()),+        state.get("documentCount").and_then(|v| v.as_u64()),+    ) {+        if ready {+            crate::session_history::observe(p, created, n, "readinessPoll");+        }+    }     let unverifiable = verdict.is_none();     if unverifiable {         based_on.push("graceAfterFirstSeen");@@ -166,6 +171,17 @@ pub fn assess(ctx: &crate::verbs::Ctx) -> Value {     }) } +/// Has this bridge VERIFIED that session `pid` finished starting? Read from the+/// per-session cache; no COM.+pub fn known_ready(pid: u32) -> bool {+    SESSIONS+        .lock()+        .unwrap_or_else(|e| e.into_inner())+        .as_ref()+        .and_then(|m| m.get(&(pid as i64)).map(|s| s.ready))+        .unwrap_or(false)+}+ /// What a verb does to SOLIDWORKS, for the gate. #[derive(Clone, Copy, PartialEq, Eq)] pub enum Touch {@@ -181,8 +197,33 @@ pub enum Touch { /// unverifiable past the grace), or not running at all (the verb then answers /// that for itself), or the host could not say (the verb's own call meets the /// same failure and reports it). `Some` is the refusal.-pub fn refuse_until_ready(ctx: &crate::verbs::Ctx, verb: &str, touch: Touch) -> Option<Value> {+/// What the gate saw, for the verb's reply (0.9.6, G3): every gated verb —+/// success or refusal — carries `readiness: {ready, state, basedOn, readsAllowed,+/// evaluatedAt:"gate"}`, so a live run can prove no COM verb ran while starting.+pub fn gate_summary(r: &Value) -> Value {+    json!({+        "ready": r.get("ready").cloned().unwrap_or(Value::Null),+        "state": r.get("state").cloned().unwrap_or(Value::Null),+        "basedOn": r.get("basedOn").cloned().unwrap_or_else(|| json!([])),+        "readsAllowed": r.get("readsAllowed").cloned().unwrap_or(Value::Null),+        "running": r.get("running").cloned().unwrap_or(Value::Null),+        "why": r.get("why").cloned().unwrap_or(Value::Null),+        "evaluatedAt": "gate",+    })+}++/// The gate itself. `Ok(summary)` lets the verb run (the summary goes into its+/// reply); `Err(refusal)` is the refusal, which carries the full assessment.+pub fn gate(ctx: &crate::verbs::Ctx, verb: &str, touch: Touch) -> Result<Value, Value> {     let r = assess(ctx);+    match refusal_for(&r, verb, touch) {+        None => Ok(gate_summary(&r)),+        Some(refusal) => Err(refusal),+    }+}++fn refusal_for(r: &Value, verb: &str, touch: Touch) -> Option<Value> {+    let r = r.clone();     if r.get("ready").and_then(|v| v.as_bool()) != Some(false) {         return None;     }
src/session_history.rs+101−16
@@ -13,7 +13,11 @@ //! the current document count, and the quit ladder needs to know it. //! //! Recorded per session — (pid, creation time) — in `session-history.json` in the-//! data root, whenever a verb shows a document open in it. `None` (UNKNOWN) when+//! data root: TRUE when the bridge itself created or opened a document in it, or+//! when a document count > 0 persists across two polls of the STARTED session+//! (never from a count read while starting — live 2026-10-01, pid 29108, such a+//! read recorded a session no one had opened a document in). ADVISORY since+//! 0.9.6: the quit ladder's priming no longer reads it. `None` (UNKNOWN) when //! the bridge did not watch the session from its start: it attached to one it //! did not launch, so a document may have come and gone unseen. @@ -35,15 +39,8 @@ fn load() -> serde_json::Map<String, Value> {         .unwrap_or_default() } -/// Record that this session has held a document. Keeps the twenty most recent-/// sessions; older ones cannot be running any more.-pub fn mark(pid: u32, created: Option<u64>) {-    let mut m = load();-    let k = key(pid, created);-    if m.get(&k).and_then(|v| v.get("everHadDocument")).and_then(|v| v.as_bool()) == Some(true) {-        return;-    }-    m.insert(k, json!({"everHadDocument": true, "atMs": crate::launchos::epoch_ms() as u64}));+fn save(m: serde_json::Map<String, Value>) {+    let mut m = m;     while m.len() > 20 {         let oldest = m             .iter()@@ -59,6 +56,76 @@ pub fn mark(pid: u32, created: Option<u64>) {     let _ = crate::launch::write_atomic(&path(), &Value::Object(m).to_string()); } +/// Record that this session has held a document BECAUSE THE BRIDGE ITSELF+/// created or opened one in it — the only single event that is proof.+pub fn mark(pid: u32, created: Option<u64>) {+    set_true(pid, created, "the bridge itself created or opened a document in this session");+}++fn set_true(pid: u32, created: Option<u64>, basis: &str) {+    let mut m = load();+    let k = key(pid, created);+    let mut entry = m.get(&k).cloned().unwrap_or_else(|| json!({}));+    if entry.get("everHadDocument").and_then(|v| v.as_bool()) == Some(true) {+        return;+    }+    if let Value::Object(e) = &mut entry {+        e.insert("everHadDocument".into(), json!(true));+        e.insert("basis".into(), json!(basis));+        e.insert("atMs".into(), json!(crate::launchos::epoch_ms() as u64));+    }+    m.insert(k, entry);+    save(m);+}++/// Two observations count as two polls only if they are this far apart.+const DISTINCT_POLL_MS: u64 = 1000;++/// WHAT A POLL SAW: a document count read on a session the bridge has VERIFIED+/// ready (callers never pass a count read while starting). Recorded with its+/// time and source (the last eight are kept). It sets `everHadDocument` only+/// when a count > 0 PERSISTS across two distinct polls with no zero between them+/// — live 2026-10-01 (pid 29108), a single count read near startup recorded a+/// session nobody had opened a document in.+pub fn observe(pid: u32, created: Option<u64>, count: u64, source: &str) {+    let mut m = load();+    let k = key(pid, created);+    let now = crate::launchos::epoch_ms() as u64;+    let mut entry = m.get(&k).cloned().unwrap_or_else(|| json!({}));+    let mut obs: Vec<Value> =+        entry.get("observations").and_then(|v| v.as_array()).cloned().unwrap_or_default();+    obs.push(json!({"count": count, "atMs": now, "source": source}));+    while obs.len() > 8 {+        obs.remove(0);+    }+    // PERSISTED: an earlier observation with a count > 0, at least+    // DISTINCT_POLL_MS older than this one, and no zero-count observation since.+    let mut persisted = false;+    if count > 0 {+        for o in obs.iter().rev().skip(1) {+            if o.get("count").and_then(|c| c.as_u64()).unwrap_or(0) == 0 {+                break;+            }+            let at = o.get("atMs").and_then(|a| a.as_u64()).unwrap_or(now);+            if now.saturating_sub(at) >= DISTINCT_POLL_MS {+                persisted = true;+                break;+            }+        }+    }+    if let Value::Object(e) = &mut entry {+        e.insert("observations".into(), json!(obs));+        if !e.contains_key("atMs") {+            e.insert("atMs".into(), json!(now));+        }+    }+    m.insert(k, entry);+    save(m);+    if persisted {+        set_true(pid, created, "a document count > 0 on two distinct polls of the started session");+    }+}+ /// `Some(true)`: this session has held a document. `Some(false)`: NONE SEEN BY /// THIS BRIDGE — it launched the session and no poll since has seen a document /// (a non-zero `GetDocumentCount` at any readiness poll, or a verb reply showing@@ -100,7 +167,18 @@ pub fn note_reply(ctx: &crate::verbs::Ctx, command: &str, reply: &Value) {         .ok()         .and_then(|ps| ps.iter().find(|p| p.pid == pid).and_then(|p| p.created_at))         .or_else(|| state.get("processCreatedAt").and_then(|v| v.as_u64()));-    mark(pid, created);+    if made_one {+        mark(pid, created);+    } else if crate::readiness::known_ready(pid) {+        // A COUNT IN A REPLY is a poll like any other: evidence only on a started+        // session, and only once it persists.+        let n = ["documentCount", "count", "openDocuments"]+            .iter()+            .filter_map(|k| reply.get(*k).and_then(|v| v.as_u64()))+            .max()+            .unwrap_or(0);+        observe(pid, created, n, command);+    } }  /// THE SESSION'S HISTORY, for `swx_status` — so a caller can predict whether a@@ -108,11 +186,13 @@ pub fn note_reply(ctx: &crate::verbs::Ctx, command: &str, reply: &Value) { /// says which evidence decided it. pub fn describe(pid: u32, created: Option<u64>) -> Value {     let ever = ever_had_document(pid, created);+    let rec = load().get(&key(pid, created)).cloned().unwrap_or(Value::Null);+    let recorded_basis = rec.get("basis").and_then(|v| v.as_str()).map(String::from);     let basis = match ever {-        Some(true) => "a document was seen open in this session",-        Some(false) => "none seen by this bridge: it launched this session, and no poll it has made since \-                        (GetDocumentCount on every readiness check, and every verb reply) has seen a \-                        document open — a document opened and closed between two polls would not be seen",+        Some(true) => recorded_basis.as_deref().unwrap_or("a document was seen open in this session"),+        Some(false) => "none seen by this bridge: it launched this session, it has not created or opened a \+                        document in it, and no document count > 0 has persisted across two polls of the \+                        started session — a document opened and closed between two polls would not be seen",         None => "the bridge did not watch this session from its start, so a document may have come and gone unseen",     };     json!({@@ -120,8 +200,13 @@ pub fn describe(pid: u32, created: Option<u64>) -> Value {         "basis": basis,         "processId": pid,         "processCreatedAt": created,+        // ADVISORY ONLY (0.9.6): no decision reads this — priming triggers on the+        // measured decline itself.+        "advisory": true,+        "observations": rec.get("observations").cloned().unwrap_or_else(|| json!([])),         "note": "MEASURED 2026-10-01: a session that has never held a document quit without help at \                  ~25 s old but DECLINED ExitApp at 50 s, 130 s, 150 s and 8 min old; swx_stop primes \-                 such a session (userAuthorized, empty, no dialog) before asking again.",+                 an EMPTY session whose ExitApp was declined (userAuthorized, no blocking dialog) and \+                 asks once more — whatever this history says, which is advisory.",     }) }
src/verbs.rs+30−11
@@ -1857,12 +1857,13 @@ fn prime_session(     id: &crate::quit::Identity, ) -> Result<String, String> {     use crate::quit;+    // THE SESSION'S HISTORY IS ADVISORY — reported, never a guard (0.9.6, P1).+    // Live 2026-10-01 (pid 29108, never driven, no document ever opened): a+    // startup poll recorded everHadDocument:true, the guard refused to prime, and+    // the quit was declined twice. The trigger is the measured condition itself:+    // rung (a) declined, the session empty (re-checked below), no blocking+    // dialog, userAuthorized — once per swx_stop call.     let ever = crate::session_history::ever_had_document(id.pid, Some(id.created_at));-    if ever == Some(true) {-        return Err("this session has held documents, so its decline is not the \-                    never-had-a-document kind priming answers"-            .into());-    }     let count = ctx         .call("app_state", json!({}), 20)         .ok()@@ -3142,14 +3143,24 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value {     // was still initialising. Reads may run on an unverifiable session after the     // grace; mutations never do. See src/readiness.rs.     let mut gated_args: Option<Value> = None;+    // WHAT THE GATE SAW goes into every gated verb's reply, success or refusal+    // (0.9.6, G3), so a live run can prove no COM verb ran while starting.+    let mut at_gate: Option<Value> = None;     // BEFORE the handler table: several arms return directly (swx_show_*, the     // export verbs), and a gate placed after the table never saw them.     if is_known(command) && !is_startup_exempt(command) {-        if let Some(refusal) =-            crate::readiness::refuse_until_ready(ctx, command, startup_touch(command))-        {-            match stop_window_rungs_during_startup(command, args) {-                Some(a) => gated_args = Some(a),+        match crate::readiness::gate(ctx, command, startup_touch(command)) {+            Ok(summary) => at_gate = Some(summary),+            Err(refusal) => match stop_window_rungs_during_startup(command, args) {+                Some(a) => {+                    let mut summary =+                        crate::readiness::gate_summary(refusal.get("readiness").unwrap_or(&Value::Null));+                    if let Value::Object(m) = &mut summary {+                        m.insert("gatePassedFor".into(), json!("windowMessageRungs"));+                    }+                    at_gate = Some(summary);+                    gated_args = Some(a);+                }                 None => {                     let mut refusal = refusal;                     if command == "swx_stop" {@@ -3165,11 +3176,19 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value {                     }                     return refusal;                 }-            }+            },         }     }     let args: &Value = gated_args.as_ref().unwrap_or(args);+    let mut out = dispatch_after_gate(ctx, command, args);+    if let (Some(summary), Value::Object(m)) = (at_gate, &mut out) {+        m.entry("readiness").or_insert(summary);+    }+    out+} +/// The handler table and the post-processing, after the startup gate.+fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {     let handler: fn(&Ctx, &Value) -> Value = match command {         "swx_status" => swx_status,         "swx_readiness" => swx_readiness,
src/verbs3.rs+51−7
@@ -731,12 +731,20 @@ fn dialog_census_now(ctx: &Ctx) -> Option<crate::quit::DialogCensus> {         .and_then(|v| v.as_u64())         .map(|p| p as u32)?;     let census = os.processes().ok()?;-    let id = crate::quit::Identity {-        pid,-        created_at: census.iter().find(|p| p.pid == pid).and_then(|p| p.created_at)?,-        main_hwnd: None,-    };-    let pids = crate::quit::dialog_census_pids(Ok(&census), &id)?;+    let created_at = census.iter().find(|p| p.pid == pid).and_then(|p| p.created_at)?;+    dialog_census_of(os.as_ref(), &census, pid, created_at)+}++/// The judged census for one SOLIDWORKS process — no COM involved: the windows+/// of the process and its children, each judged by `quit::judge_window`.+fn dialog_census_of(+    os: &dyn crate::launchos::LaunchOs,+    census: &Vec<crate::launchos::ProcInfo>,+    pid: u32,+    created_at: u64,+) -> Option<crate::quit::DialogCensus> {+    let id = crate::quit::Identity { pid, created_at, main_hwnd: None };+    let pids = crate::quit::dialog_census_pids(Ok(census), &id)?;     let mut windows: Vec<crate::launchos::WindowInfo> = Vec::new();     for p in &pids {         match os.windows_of_pid(*p) {@@ -750,7 +758,7 @@ fn dialog_census_now(ctx: &Ctx) -> Option<crate::quit::DialogCensus> {     // answering — nothing here presses anything.     // JUDGED BY MEASURED MODALITY (0.9.4): a notification — owner frame enabled,     // a tool window, nothing to click — does not stop a sweep or an import.-    Some(crate::quit::dialog_census(&windows, os.as_ref()))+    Some(crate::quit::dialog_census(&windows, os)) }  /// `swx_close_all_safely` — PRESERVE EVERYTHING, THEN CLOSE EVERYTHING.@@ -3105,6 +3113,7 @@ pub fn window_info(ctx: &Ctx, _args: &Value) -> Value {         Ok(all) => {             let ours = crate::abwin::solidworks_windows(&all, hwnd, pid);             ok(json!({+                "dialogCensus": judged_dialog_census_without_com(),                 "mainHwnd": hwnd,                 "abAvailable": true,                 "windowCount": ours.len(),@@ -3121,6 +3130,41 @@ pub fn window_info(ctx: &Ctx, _args: &Value) -> Value {     } } +/// THE BRIDGE'S OWN VERDICT on every dialog of the running SOLIDWORKS —+/// blocking or notification, with the evidence — taken WITHOUT COM (the process+/// census names the one SOLIDWORKS), so it is available during startup and to a+/// gate that must not act on a window it has merely seen (0.9.6, G2).+fn judged_dialog_census_without_com() -> Value {+    let os = crate::launchos::os();+    let census = match os.processes() {+        Ok(c) => c,+        Err(e) => return json!({"readable": false, "why": format!("no process census: {e}")}),+    };+    let sw: Vec<&crate::launchos::ProcInfo> =+        census.iter().filter(|p| p.image.eq_ignore_ascii_case("SLDWORKS.exe")).collect();+    let (pid, created) = match sw.as_slice() {+        [] => return json!({"readable": true, "solidworksRunning": false, "blocking": Value::Null, "notifications": []}),+        [one] => match one.created_at {+            Some(c) => (one.pid, c),+            None => return json!({"readable": false, "why": "the SOLIDWORKS process's creation time could not be read"}),+        },+        many => {+            return json!({"readable": false,+                          "why": format!("{} SOLIDWORKS processes are running; the census cannot say which is meant", many.len())})+        }+    };+    match dialog_census_of(os.as_ref(), &census, pid, created) {+        None => json!({"readable": false, "processId": pid,+                       "why": "the windows of SOLIDWORKS or its children could not be read"}),+        Some(c) => json!({+            "readable": true,+            "processId": pid,+            "blocking": c.blocking.map(|d| d.to_json()),+            "notifications": c.notifications.iter().map(|d| d.to_json()).collect::<Vec<_>>(),+        }),+    }+}+ /// `swx_dismiss_blocking_dialogs` — REPORT by default; closing is opt-in. /// /// Learned live, the hard way, on 2026-09-02: closing a stuck SOLIDWORKS modal
tests/quit.rs+55−12
@@ -748,12 +748,14 @@ fn the_same_window_over_a_disabled_frame_blocks_and_says_why() {  // ── 0.9.5 (adversary pass 42): PRIMING'S OTHER END, AND QUIT DURING STARTUP ─ -/// A SESSION THAT HAS HELD A DOCUMENT IS NEVER PRIMED. Both ends of the rule:-/// the bridge RECORDS that the session held one (a scratch part, created and-/// closed), and the prime's guard READS it — so a declined rung (a) on that-/// session stands, nothing is created, and the attempt says why.+/// THE SESSION'S HISTORY IS ADVISORY — NEVER A GUARD ON PRIMING (0.9.6, P1).+/// Live 2026-10-01 (pid 29108): a false `everHadDocument:true` made the guard+/// refuse to prime, and the quit was declined twice. The trigger is the+/// measured condition itself: rung (a) declined, the session empty, no blocking+/// dialog, userAuthorized. Here the history is TRUE (the bridge made and closed+/// a part) and the session is still primed once and asked again. #[test]-fn a_session_that_has_held_a_document_is_never_primed() {+fn a_session_that_has_held_a_document_is_still_primed_when_empty() {     let m = Sim::new("quit-prime-held", json!({"a": "declined"}));     let b = m.boot(&[]);     b.call("swx_start", json!({}));@@ -764,22 +766,63 @@ fn a_session_that_has_held_a_document_is_never_primed() {     let st = b.call("swx_status", json!({}));     check!(         b,-        "swx_status: the session is recorded as having held a document",-        st["solidworks"]["sessionHistory"]["everHadDocument"] == true,+        "swx_status: the session is recorded as having held a document (advisory)",+        st["solidworks"]["sessionHistory"]["everHadDocument"] == true+            && st["solidworks"]["sessionHistory"]["advisory"] == true,         s(&st["solidworks"]["sessionHistory"])     );     let r = b.call("swx_stop", authorized("a"));     let tries = r["rungsTried"].as_array().cloned().unwrap_or_default();     check!(         b,-        "swx_stop: the decline stands, unprimed, and says the session has held documents",-        r["stopped"] == false-            && tries.len() == 1+        "swx_stop: declined, primed ONCE, asked again — whatever the history says",+        tries.len() == 2             && tries[0]["result"] == "declined"-            && tries[0]["detail"].as_str().unwrap_or("").contains("has held documents"),+            && tries[0]["primed"] == false+            && tries[1]["primed"] == true,+        s(&r)+    );+    check!(b, "swx_stop: the prime is in the ledger", ledger_has(&b, "quit_prime"), String::new());+}++/// A PHANTOM DOCUMENT COUNT NEVER RECORDS HISTORY, AND PRIMING STILL FIRES. The+/// live shape (pid 29108): a session nobody opened a document in read a non-zero+/// count while starting. Simulated twice over: a count of 1 for as long as the+/// start is incomplete, and — after the start — a count of 1 on ONE poll only.+/// Neither is evidence; the declined quit is primed and SOLIDWORKS quits.+#[test]+fn a_phantom_document_count_never_records_history_and_priming_still_fires() {+    let m = Sim::new("quit-prime-phantom", json!({"a": "exitedIfEverHadDocument"}));+    let b = m.boot(&[]);+    std::fs::write(b.scratch.join("mock-startup-incomplete"), "").unwrap();+    std::fs::write(b.scratch.join("mock-phantom-doc-while-starting"), "").unwrap();+    b.call("swx_start", json!({}));+    // Polls 400 ms apart for 1.6 s: a count read while starting, persisting —+    // still never evidence.+    for _ in 0..5 {+        b.call("swx_status", json!({}));+        std::thread::sleep(std::time::Duration::from_millis(400));+    }+    std::fs::remove_file(b.scratch.join("mock-startup-incomplete")).unwrap();+    std::fs::write(b.scratch.join("mock-phantom-doc-once"), "").unwrap();+    b.call("swx_status", json!({}));+    std::thread::sleep(std::time::Duration::from_millis(1100));+    let st = b.call("swx_status", json!({}));+    let h = &st["solidworks"]["sessionHistory"];+    check!(+        b,+        "swx_status: neither a count read while starting nor a one-poll count records a document",+        h["everHadDocument"] != true,+        s(h)+    );+    let r = b.call("swx_stop", authorized("a"));+    let tries = r["rungsTried"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "swx_stop: declined, primed, and then it quits",+        r["stopped"] == true && tries.len() == 2 && tries[1]["primed"] == true && tries[1]["result"] == "quit",         s(&r)     );-    check!(b, "swx_stop: ...and no scratch part was made to prime it", !ledger_has(&b, "quit_prime"), String::new()); }  /// `false` MEANS "NONE SEEN BY THIS BRIDGE", and says so.
tests/readiness.rs+122−8
@@ -505,9 +505,17 @@ fn an_on_demand_add_in_needs_quiet_and_is_never_the_3dexperience_add_in() {         ),         (             "ready-ondemand-3dx",-            "<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n<LOAD_ADDIN> SolidWorks Toolbox\n<LOAD_ADDIN> 3DEXPERIENCE\n",+            "<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n<LOAD_ADDIN> SolidWorks Toolbox\n\+             <LOAD_ADDIN> \"SWK3DCompassAddin.dll\" \"3DEXPERIENCE for WORKS\"\n",             300,-            "a quiet log whose LOAD_ADDIN after the marker names the 3DEXPERIENCE add-in",+            "a quiet log whose LOAD_ADDIN after the marker is the platform add-in (SWK3DCompassAddin.dll)",+        ),+        (+            "ready-header-scope",+            "<LOAD_ADDIN> old\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n; SolidWorks generated log file - do not edit.\n\+             <VERSION> 6</VERSION>\n<PROC_NUM>8</PROC_NUM>\n",+            0,+            "a marker from BEFORE the start's header (the file is rewritten at every start)",         ),     ] {         let scratch = TempDir::new(tag);@@ -542,6 +550,25 @@ fn an_on_demand_add_in_needs_quiet_and_is_never_the_3dexperience_add_in() {         r["errorCode"] == "host_app_starting" && r["startingUnverifiable"] == true,         s(&r)     );+    // An add-in whose NAME carries "3DEXPERIENCE" but is not the platform —+    // measured 2026-10-01: SaveFolderTo3DEXPERIENCEAddin2.DLL — loaded after the+    // marker in a quiet log is on-demand, not a start.+    let scratch = TempDir::new("ready-ondemand-savefolder");+    session(&scratch, json!([]));+    let logp = write_log(+        &scratch,+        "<LOAD_ADDIN> \"SWK3DCompassAddin.dll\" \"3DEXPERIENCE for WORKS\"\n<3DXINITERROR>0\n\+         <LOAD_ADDIN> \"SaveFolderTo3DEXPERIENCEAddin2.DLL\" \"Batch Save to 3DEXPERIENCE\"\n",+        120,+    );+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+    let st = b.call("swx_start", json!({}));+    check!(+        b,+        "swx_start: a non-platform add-in naming 3DEXPERIENCE, after the marker, quiet => on demand, ready",+        st["ready"] == true && st["readiness"]["performanceLog"]["verdict"] == "onDemandAddInAfterComplete",+        s(&st)+    );     // ...and the on-demand case itself, with the rule's basis in the answer.     let scratch = TempDir::new("ready-ondemand-ok");     session(&scratch, json!([]));@@ -562,11 +589,12 @@ fn an_on_demand_add_in_needs_quiet_and_is_never_the_3dexperience_add_in() {     ); } -/// A DOCUMENT SEEN AT ANY POLL IS RECORDED. The user's own document, open when-/// the bridge first looks, never passed through a verb that "made one" — the-/// readiness poll's GetDocumentCount records it.+/// A DOCUMENT THAT PERSISTS ACROSS TWO POLLS IS RECORDED (0.9.6). The user's+/// own document, open when the bridge first looks, never passed through a verb+/// that "made one": the readiness polls record it — once the count has been+/// > 0 on two distinct polls of the STARTED session, never from one read. #[test]-fn a_document_seen_at_any_poll_marks_the_session() {+fn a_document_that_persists_across_two_polls_marks_the_session() {     let scratch = TempDir::new("ready-poll-marks");     session(         &scratch,@@ -575,11 +603,97 @@ fn a_document_seen_at_any_poll_marks_the_session() {     );     let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);     b.call("swx_start", json!({}));+    let first = b.call("swx_status", json!({}));+    let h1 = first["solidworks"]["sessionHistory"].clone();+    check!(+        b,+        "swx_status: what the poll saw is recorded, with its count",+        h1["observations"].as_array().map(|a| a.iter().any(|o| o["count"] == 1)).unwrap_or(false),+        s(&h1)+    );+    std::thread::sleep(std::time::Duration::from_millis(1100));     let st = b.call("swx_status", json!({}));     check!(         b,-        "swx_status: the session has held a document, recorded by the poll",-        st["solidworks"]["sessionHistory"]["everHadDocument"] == true,+        "swx_status: the count persisted across two polls — the session has held a document",+        st["solidworks"]["sessionHistory"]["everHadDocument"] == true+            && st["solidworks"]["sessionHistory"]["advisory"] == true,         s(&st["solidworks"])     ); }++/// EVERY GATED VERB SAYS WHAT THE GATE SAW (0.9.6, G3) — on success as well as+/// on refusal — so a live run can prove no COM verb ran while starting. Including+/// the arms that return straight from the handler table (swx_show_*, exports).+#[test]+fn every_gated_reply_carries_the_readiness_the_gate_evaluated() {+    let scratch = TempDir::new("ready-at-gate");+    session(&scratch, json!([]));+    std::fs::write(scratch.join("mock-startup-incomplete"), "").unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    let refused = b.call("swx_docs", json!({}));+    check!(+        b,+        "refusal: readiness.ready false, with basedOn",+        refused["errorCode"] == "host_app_starting"+            && refused["readiness"]["ready"] == false+            && refused["readiness"]["basedOn"].as_array().map(|a| !a.is_empty()).unwrap_or(false),+        s(&refused)+    );+    std::fs::remove_file(scratch.join("mock-startup-incomplete")).unwrap();+    for (verb, args) in [+        ("swx_docs", json!({})),+        ("swx_new_document", json!({"type": "part"})),+        ("swx_show_part", json!({"path": "C:\\Users\\drew\\work\\nothing-here.SLDPRT"})),+        ("swx_get_app_state", json!({})),+    ] {+        let r = b.call(verb, args);+        check!(+            b,+            &format!("{verb}: the reply carries readiness as evaluated at the gate"),+            r["readiness"]["evaluatedAt"] == "gate"+                && r["readiness"]["ready"] == true+                && r["readiness"]["basedOn"].as_array().map(|a| !a.is_empty()).unwrap_or(false),+            s(&r)+        );+    }+    let free = b.call("swx_find_files", json!({"root": "/nonexistent"}));+    check!(b, "an exempt verb is not gated and says nothing of a gate", free["readiness"].is_null(), s(&free));+}++/// THE TWO-POLL RULE, both edges (0.9.6). Two counts > 0 read within a second+/// are one blip, not persistence; and a zero between two counts breaks the run.+#[test]+fn a_blip_or_an_interrupted_count_never_records_a_document() {+    let scratch = TempDir::new("ready-blip");+    session(&scratch, json!([]));+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    // A blip: a count of 1 on two reads close together, then 0.+    std::fs::write(scratch.join("mock-phantom-doc-once"), "2").unwrap();+    b.call("swx_docs", json!({}));+    b.call("swx_docs", json!({}));+    std::thread::sleep(std::time::Duration::from_millis(1100));+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "two counts > 0 within a second, then zero: not a document",+        st["solidworks"]["sessionHistory"]["everHadDocument"] != true,+        s(&st["solidworks"]["sessionHistory"])+    );+    // Interrupted: 1 ... 0 ... (a second later) 1.+    std::fs::write(scratch.join("mock-phantom-doc-once"), "1").unwrap();+    std::fs::remove_file(scratch.join("mock-phantom-doc-once.used")).unwrap();+    b.call("swx_docs", json!({}));+    b.call("swx_docs", json!({}));+    std::thread::sleep(std::time::Duration::from_millis(1100));+    std::fs::remove_file(scratch.join("mock-phantom-doc-once.used")).unwrap();+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "a count, a zero, a count a second later: the zero breaks the run — not a document",+        st["solidworks"]["sessionHistory"]["everHadDocument"] != true,+        s(&st["solidworks"]["sessionHistory"])+    );+}
tests/wiring.rs+8−1
@@ -126,7 +126,7 @@ const GUARDS: &[(&str, &str)] = &[      "data an older bridge left in ab's install cache is copied out before anything reads the data root"),     ("legacy_roots",      "the listing and the migration still read every location older bridges wrote to"),-    ("refuse_until_ready",+    ("readiness::gate",      "no document command reaches a SOLIDWORKS that has not finished starting (crash measured 2026-10-01)"),     ("is_startup_exempt",      "every verb not on the exemption list waits for SOLIDWORKS to finish starting (adversary pass 42)"),@@ -1004,6 +1004,13 @@ fn the_quit_gates_status_claim_fails_on_a_declined_quit() {         // No interpreter, no gate to run: not a defect in the gate.         Err(_) => return,     };+    // G2 (0.9.6): the gate decides on the BRIDGE's dialog verdict, never on a+    // raw list of visible #32770 windows (the Connected notice is one).+    let src = std::fs::read_to_string(root.join("tools").join("quit-gate.py")).unwrap_or_default();+    assert!(+        !src.contains("if base[\"dialogs\"]:") && src.contains("dialog_verdict(base.get(\"judged\"))"),+        "tools/quit-gate.py refuses on a raw window list again — it must use the bridge's verdict"+    );     let text = String::from_utf8_lossy(&out.stdout).to_string();     assert!(         out.status.success() && text.contains("SELF-TEST PASSED"),
tools/quit-gate.py+58−9
@@ -358,6 +358,26 @@ def identity_of(rows):     return (sw[0]["pid"], sw[0]["created"]) if sw else None  +def dialog_verdict(judged):+    """The BRIDGE's verdict decides, never a raw window list. Returns+    ("clear" | "blocking" | "unknown", message). Only a BLOCKING dialog is handed+    to the user (a screenshot, their decision); a notification is listed and the+    gate goes on; an unreadable census refuses without blaming the user's screen."""+    if not isinstance(judged, dict) or judged.get("readable") is not True:+        return ("unknown", "the bridge could not judge the dialogs on screen (%s) — whether one "+                           "blocks is UNKNOWN, so the gate does not quit on a guess. Nothing was "+                           "attempted." % ((judged or {}).get("why") if isinstance(judged, dict) else judged))+    b = judged.get("blocking")+    if b:+        return ("blocking", "a BLOCKING dialog is on screen (title=%r kind=%s: %s). This bridge never "+                            "answers one, and a gate must not either. A blocking dialog is the one "+                            "thing that is genuinely the user's to answer — show them a screenshot "+                            "(swx_state) and let them decide. Closing documents is NOT theirs to do: "+                            "the sweep does that." % (b.get("title"), b.get("kind"), b.get("why")))+    return ("clear", "no blocking dialog (%d notification(s) listed, never answered)"+            % len(judged.get("notifications") or []))++ def is_dialog(w):     return str(w.get("class") or "") == "#32770" @@ -408,6 +428,12 @@ def baseline():         print("  SC_CLOSE on the main frame: 0x%X — Close is %s" %               (state, "DISABLED" if state & 0x3 else "ENABLED")) +    # THE BRIDGE'S VERDICT on those windows (0.9.6, G2): blocking vs notification,+    # judged by measured modality and by the window's words. A visible #32770 is+    # NOT a dialog somebody must answer — the 3DEXPERIENCE "Connected" notice+    # (title '') is one, and refusing on it made this gate refuse its own baseline.+    judged = verb("swx_window_info", {}, 60).get("dialogCensus")+    show("the bridge's dialog census (blocking vs notification)", judged)     st = verb("swx_status", {}, 60)     app = verb("swx_get_app_state", {}, 60)     show("swx_status.solidworks (the bridge's own reading, recorded, not trusted)",@@ -427,7 +453,7 @@ def baseline():     # gate ASSERTED that in its docstring and checked nothing.     require_bridge_launched(st, created)     return {"pid": pid, "created": created, "procs": rows, "windows": wins,-            "dialogs": dialogs, "status": st, "app": app}+            "dialogs": dialogs, "judged": judged, "status": st, "app": app}   def to_epoch(iso):@@ -623,13 +649,14 @@ def prove_zero_documents(base):     if isinstance(listed, list) and len(listed) != 0:         raise Refused("the count says 0 but the enumeration lists %d — they DISAGREE, so neither "                       "is evidence." % len(listed))-    if base["dialogs"]:-        raise Refused("a dialog is on screen (%s). This bridge never answers one, and a gate must "-                      "not either. A DIALOG is the one thing that is genuinely the user's to answer — "-                      "this bridge never answers one — so show them a screenshot (swx_state) and "-                      "let them decide. Closing documents is NOT theirs to do: the sweep above "-                      "does that." %-                      [w.get("title") for w in base["dialogs"]])+    claim, why = dialog_verdict(base.get("judged"))+    if claim == "blocking":+        raise Refused(why)+    if claim == "unknown":+        raise Refused(why)+    for n in (base.get("judged") or {}).get("notifications") or []:+        print("  NOTIFICATION on screen (not blocking, never answered): title=%r kind=%s why=%s"+              % (n.get("title"), n.get("kind"), n.get("why")))     if base["status"].get("solidworks", {}).get("busy") is True:         raise Refused("SOLIDWORKS reports BUSY — a rung would land on a modal operation.")     check(True, "0 documents, 0 unsaved, count agrees with the enumeration, no dialog")@@ -776,12 +803,34 @@ def self_test():         if got != want or (len(FAILURES) > before) == want:             bad.append(label)     del FAILURES[:]+    # THE DIALOG VERDICT (0.9.6, G2): the measured Connected notice — title '',+    # judged a notification by the bridge — must NOT refuse the gate; a blocking+    # dialog must; an unreadable census must refuse WITHOUT telling the reader to+    # hand a dialog to the user.+    notice = {"title": "", "kind": "notification", "blocking": False,+              "why": "a tool window whose owner frame is ENABLED"}+    verdicts = [+        ("the Connected notification alone", {"readable": True, "blocking": None, "notifications": [notice]}, "clear"),+        ("a blocking save prompt", {"readable": True, "blocking": {"title": "SOLIDWORKS", "kind": "save_changes",+                                    "why": "by its text"}, "notifications": [notice]}, "blocking"),+        ("an unreadable census", {"readable": False, "why": "no process census"}, "unknown"),+        ("no census at all", None, "unknown"),+    ]+    for label, judged, want in verdicts:+        got, msg = dialog_verdict(judged)+        if got != want:+            bad.append("dialog verdict: " + label)+        if want == "unknown" and "screenshot" in msg:+            bad.append("dialog verdict: an unreadable census told the reader to show the user a screenshot")+        if want == "clear" and "screenshot" in msg:+            bad.append("dialog verdict: a notification was handed to the user")     print("\n" + "=" * 72)     if bad:         print("SELF-TEST FAILED — the status claim gave the wrong verdict for: " + "; ".join(bad))         return 1     print("SELF-TEST PASSED — the status claim FAILS on a declined quit and an unreadable status, "-          "and PASSES only on running:false.")+          "and PASSES only on running:false; the dialog verdict lists a notification and refuses "+          "only on a blocking dialog or an unreadable census.")     return 0