app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
docs: launch resilience (0.4.x rounds + live proof), quit investigation, import-temp leak, ecosystem rows 36–43
4 files changed
+2516−3
docs/ECOSYSTEM-TABLE.md+11−3@@ -66,8 +66,8 @@ templates**, alisamsam **22**. | # | Capability | Fusion bridge | **swx v0.3** | SolidPilot **(AGPL-3.0!)** | vespo92 (MIT) | bartels (MIT) | Schildkraut (MIT) | alisamsam (MIT) | |---:|---|---|---|---|---|---|---|---|-| 1 | Session lifecycle — attach, stop, kill | ✓ | **✓ live** attach · ⚠ mock stop/kill | ✓ attach | ? | ✓ attach | ? | ✓ attach |-| 2 | Launch the CAD application (and install it) | ✓ launch + silent install | **WIP** — refused by SOLIDWORKS; 3DEXPERIENCE chain found 2026-09-03 | ✓ claims COM launch | ✗ | ✗ needs SW already open | ? | ✗ needs running instance |+| 1 | Session lifecycle — attach, stop, kill | ✓ | **✓ live** attach · stop: **unproven** — the one observed `ExitApp` "decline" had a dirty Part1 open (save-prompt stall, not a decline); no stop event at 0 docs is in the ledger. v1 also holds a COM ref while checking and gates on unsaved-count not open-count (docs/QUIT-INVESTIGATION.md); graceful-quit ladder = 0.5.0 work · kill: never (policy) | ✓ attach | ? | ✓ attach | ? | ✓ attach |+| 2 | Launch the CAD application (and install it) | ✓ launch + silent install | **✓ sanctioned 3DEXPERIENCE chain shipped 0.4.x** — attributable routes, Run-Begin stall detector, in-flight idempotence; **live gate pending** (bridge-context stall measured 6/7 on the old cmd route) | ✓ claims COM launch (refused on Connected) | ✗ | ✗ needs SW already open | ? | ✗ needs running instance | | 3 | Requires an in-app add-in | yes (3 verbs to manage it) | **no — out-of-process COM** | no (C# COM server) | no | no | no | no | | 4 | Open a local file · disk discovery | ✓ open · ✗ discovery (cloud-native) | **✓ live** open · **✓** `find_files` | ✓ open · ✗ | ✓ (untested) | ✓ | ✓ | ✓ open · ✗ | | 5 | Create a new document | ✗ (docs arrive from cloud/import) | **✓ live** | ✓ part + assembly | ✓ (untested) | ✓ | ✓ | ✓ part + assembly |@@ -101,8 +101,16 @@ templates**, alisamsam **22**. | 33 | **Action ledger + measured per-verb timings** | ✓ ledger, verb times, live feed, alerts | **✓ ledger + timings recorded live** | ✗ (dev log only) | ✗ | ✗ logging only | partial ("enhanced logging") | ✗ | | 34 | **Deny-by-default write gate** | ✗ no gate, no confirm/reason in its catalog | **✓ live — unique. Two independent layers, 8 adversarial rounds, held in all 3 live runs** | ✗ | ✗ | partial ("adapter safety wrappers") | partial (macro exec needs permission) | ✗ | | 35 | Host-ecosystem integration (Adom Bridge, Activity Log, skill) | ✓ native | **✓ native** | ✗ MCP clients only | ✗ | ✗ | ✗ | ✗ |+| 36 | **Ownership persists across bridge/host restarts** (bridge-created docs stay closable without opt-in) | ✗ (no ownership model) | **✓ live 2026-09-08** — `owned-docs.json` keyed to the SOLIDWORKS process; proven create → host kill → close in default posture | ✗ | ✗ | ✗ | ✗ | ✗ |+| 37 | **Honest UNKNOWN instead of a fabricated empty/zero** (doc list, counts, dialogs, interference) | partial | **✓ 0.4.x** — `docs_enumeration_failed`, `Option` counts, `unsaved_unknown`, `count:null` semantics in hints; 29 adversary tests | ✗ | ✗ | ✗ | ✗ | ✗ |+| 38 | **Hung / exhausted host detection** (Not Responding + real COM timeout; NewDocument-returns-nothing) | ✗ | **✓ 0.4.x** evidence-based `host_app_hung` / `host_app_exhausted`, memory advisory only (measured 17.4 GB hang, 13 GB healthy) — unproven on a live hang since fix | ✗ | ✗ | ✗ | ✗ | ✗ |+| 39 | **Stalled-launcher recovery with attribution law** (kill only a chain proven ours by handle; never daemons, never sldworks) | ✗ | **✓ shipped 0.4.4**, 4 adversary rounds, **live gate pending** | ✗ | ✗ | ✗ | ✗ | ✗ |+| 40 | **Crash-reporter recovery** (click "Restart SOLIDWORKS Design" once, structured match, ledgered) | ✗ | **shipped, DISABLED by default** until the real control-tree reply is captured | ✗ | ✗ | ✗ | ✗ | ✗ |+| 41 | **COM retry-on-reject** (busy host: RPC_E_CALL_REJECTED / RETRYLATER backoff on every call) | ? | **✓ live 0.3.3** — 50-read smoke gate, 0 under-reporting, 11 rejects absorbed | ✗ | ✗ | ✗ | ✗ | ✗ |+| 42 | **Real-work proof** (multi-assembly build + BOM exported from the assembly, verified against a packet) | ✓ (electronics) | **✓ live** — NSF crate: 5 assemblies, 383 placements, 32-row BOM, 28/28 part numbers match | ✗ | ✗ | ✗ | ✗ | ✗ |+| 43 | **Import / resource lifecycle — no leaked temps, drainable, enumeration never wedges** | ✗ | **✓ 0.5.0** — enumeration degrades above a cap (`{count,truncated,sample}`, never a 30s timeout+kill); `swx_close_import_temps` drains STEP/IGES `…\Temp\swx<pid>\IC~~\…` temps in the DEFAULT posture (path is the ownership proof, survives a pid change); import asserts a net +1. Root-caused a live 1342-doc / 8 GB leak | ✗ | ✗ | ✗ | ✗ | ✗ | -*(35 numbered rows; rows 29–31 are the N/A block that accounts for most of the Fusion verb-count gap.)*+*(42 numbered rows; rows 29–31 are the N/A block that accounts for most of the Fusion verb-count gap. Rows 36–42 added 2026-09-08 from the crate build and the launch-resilience rounds — the bar is "significantly more powerful than the MCP servers", so every robustness capability gets a row with its evidence level, not a slogan.)* ## Per-competitor notes
docs/IMPORT-LEAK.mdadded+94@@ -0,0 +1,94 @@+# The STEP-import document leak (found 2026-09-08)++## Symptom+`swx_status` reported **1342 open documents**; `GetDocumentCount()` (authoritative COM) confirmed 1342;+`swx_docs` enumeration timed out at 30s (that many documents cannot be marshalled in the budget);+SOLIDWORKS private memory sat at **8.1 GB** on an otherwise idle, responding session.++## Cause (corrected after reading cmd_import)+`swx_import` is NOT itself leaking: it correctly leaves ONE open, unsaved, bridge-created document, the+same contract as `swx_new_document`. The 1342 are the **same two fixtures imported over and over and+never closed** — note the `-1`/`-2` title suffixes SOLIDWORKS adds when a prior import of the same name+is still open. Source fixtures `First.step`/`Second.step` are not in the current tree, so this is an+earlier test/run loop that imported without closing. Every open document was a STEP-import temporary:+```+title: First.step.SLDPRT Second.step.SLDPRT First.step-1.SLDPRT ...+path : C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT+```+`swx<pid>\IC~~\` is the folder SOLIDWORKS writes when converting a STEP. These docs are unsaved (temp+path = never saved to a real location). They accumulated because callers imported without closing AND —+the actual bridge defect — **before the 0.4.4 ownership fix a host respawn disowned them (bug #12), so+nothing could drain them in the default posture even if it tried.** The count is static and no loop is+running, so it is historical, not active, and never drains except by restarting SOLIDWORKS.++## Why it matters+- **This is the real root of the crate-build memory exhaustion** (0.5 GB → 13 GB across the build, then a+ hang at 17.4 GB / "52 docs"). The 7 fastener imports plus repeated import-based tests each leaked a+ loaded document; "openDocs" was never really the crate assemblies alone.+- It quietly defeats the quit ladder's precondition ("verified Some(0) documents") and makes+ `swx_docs` time out, which earlier read as the silent-empty bug.+- No MCP server in the ecosystem manages this; getting resource lifecycle right is exactly the+ robustness edge Drew asked for.++## The real bridge defects (0.5.0), in order of impact+1. **`swx_docs` / count enumeration must degrade, never time out.** Read `GetDocumentCount` first;+ above a cap, page the enumeration or return `{count, truncated:true, sample:[...],+ _hint:"N documents open; enumeration exceeds budget — likely undrained import temps, run swx_close_import_temps"}`+ instead of blocking 30s and killing the com host. `swx_docs` must not time out on a large count: read `GetDocumentCount`+ first; above a cap, page the enumeration or return `{count, truncated:true, sample:[...],+ _hint:"N documents open; enumeration exceeds budget — likely leaked import temps, see swx_purge_imports"}`+ rather than killing the com host.+2. **A drain verb.** `swx_close_import_temps` (gated, bridge-owned only): close every open document whose+ path is under `%TEMP%\swx<pid>\IC~~\`, by title/path, reporting count freed and memory reclaimed.+ These are unambiguously transient and bridge-created; never a user document.+3. Add these `IC~~` temps to the ownership model so the default posture can close them (0.4.4 already+ makes bridge-created docs survivable across respawns; confirm import temps are tracked as created).+4. **Import net-count assertion.** `swx_import` should verify `GetDocumentCount` rose by exactly 1 and+ report the resulting doc's title/path, so a caller can close it — and should carry an `_hint`+ reminding that an imported doc stays open until closed or saved, like new_document.+5. NOT a change to swx_import's core contract — it correctly returns one open unsaved doc.++## Cleanup of the current 1342+They clear on the next SOLIDWORKS restart (which the launch gate needs anyway). No user document is+among them (all are `%TEMP%\swx107688\IC~~\`), but closing 1342 live is slow and the enumeration times+out, so a restart is the clean path.++## Shipped in 0.5.0 (Workstream A)++All four defects are closed; `swx_import`'s contract is unchanged (it still+returns one open, unsaved, bridge-created document, like `swx_new_document`).++1. **Enumeration degrades, never wedges.** `swx_docs` and `app_state` read+ `GetDocumentCount` first (one COM call, always answers). Above+ `host::doc_enum_cap()` (200; `SWX_DOC_ENUM_CAP` overrides it for tests) they+ return `{count, truncated:true, sample:[first 10 titles/paths],+ importTempsInSample, _hint:"…run swx_close_import_temps"}` instead of+ marshalling 1342 handles for 30s and getting the com host killed. `app_state`+ also sets `documentsTruncated`, and the server's ownership prune is SKIPPED+ when truncated (`prune_unless_truncated`) — an empty "unlistable" set must not+ disown every document the bridge created (bug #5's shape, reached through the+ leak).+2. **`swx_close_import_temps`** (new, gated with a `reason`, NO write opt-in):+ closes every open document whose path matches `is_import_temp` —+ `…\Temp\swx<pid>\IC~~\…`. The path is the ownership proof and is stronger+ than the registry: it survives the pid change that made the 1342 (from a dead+ bridge process) un-ownable, and no user document can match it. Drains in+ bounded batches (`close_import_temps {max}` closes a batch and reports `done`;+ the verb loops to a wall budget), so neither a single com call nor the verb+ runs unbounded on a huge count.+3. **Import net-count assertion.** `cmd_import` counts via `GetDocumentCount`+ (not the enumeration, which would itself time out at 1342), asserts the count+ rose by exactly one, and returns `documentsOpened` / `netCountOk` /+ `documentCount` plus a hint that the document stays open until closed or+ saved.+4. **Ecosystem row 43** — "import / resource lifecycle: no leaked temps,+ drainable, enumeration never wedges." We are alone.++Tests: `tests/import_lifecycle.rs` (5) — net-count assertion, degrade-above-cap+(swx_docs stays fast + truncated + sampled), drain in the default posture+(never touches a `new_document`), bounded batching (`done:false` then finish),+and drain-after-a-com-host-respawn (the temps are identified by path, not by a+registry the respawn cleared). Plus coverage in the e2e verb walk.++The live 1342 still clear only on the next SOLIDWORKS restart (the launch gate+needs one anyway); `swx_close_import_temps` is what prevents the next pile.
docs/LAUNCH-RESILIENCE.mdadded+910@@ -0,0 +1,910 @@+# Launch resilience (v0.4.0)++> Drew, 2026-09-04: *"figure out how to recover from daemon kills in the future.+> This bridge must be extremely robust."*++This is the record of what was measured on Drew's machine, what the bridge now+does about it, and — kept separate and explicit — **what is still unproven until+`tools/launch-gate.py` runs against a live SOLIDWORKS.**++---++## 1. What was measured++All of this is from Drew's desktop on 2026-09-03/04. Raw captures:+`healthy-launch-tree.txt`, `launch-logs.txt`.++### The sanctioned launch++`SOLIDWORKS Design.lnk` (Start-Menu group *Dassault Systemes SOLIDWORKS+3DEXPERIENCE R2026x*), `windowStyle 1` (normal):++```+CATSTART.exe -run "SWXDesktopLauncher.exe"+ -object "--AppName=\"SWXCSWK_AP\" -tenant=OI000616940 -monoapp+ -3DRegistryURL=https://eu1-makers-registry.3dexperience.3ds.com"+ -nowindow+```++A HEALTHY launch, from the launcher's own log+(`%APPDATA%\DassaultSystemes\SWXDesktopLauncher\<yyyyMMddHHmmss>_V6R2026x.HF4.13.log`):++| t | thread | what it logs |+|---|---|---|+| +0s | first | version banner, `tenant=`, `AppName=`, `SecurityContext:` |+| **+2s** | **second** | **`Run Begin`**, then `Located …\sldworks.exe majorversion: 34` |+| +2s | | `%APPDATA%\DassaultSystemes\SWXDesktopHelper\<ts>.log` appears (sign-in / licence) |+| +3s | | `sldworks.exe` appears; its parent is already gone |+| +1-3min | | COM attach succeeds |++Two things follow, and both matter:++* **`Run Begin` is the signal.** It is logged by a SECOND thread ~2 seconds+ after `SecurityContext:`, and everything downstream depends on it.+* **The 3DEXPERIENCE daemons are NOT required.** The successful launch on+ 2026-09-04 had no Backbone running at all. `3DEXPERIENCELauncherSysTray.exe`+ was up; `3DEXPERIENCELauncher.exe` ran as a service. Nothing this bridge does+ needs to touch any of them.++### FAILURE MODE A — the launcher stalls at `SecurityContext:`++**5 of 5** bridge launches on 2026-09-04, and 1 of 2 on 2026-09-03:+`cmd.exe /c start "" "<lnk>"` spawned with `DETACHED_PROCESS | CREATE_NO_WINDOW`+produced a launcher log that **stopped at `SecurityContext:`**. No `Run Begin`,+no helper log, no `sldworks.exe`. `CATSTART.exe` + `SWXDesktopLauncher.exe` sat+idle at ~50 MB **forever**.++Every launch from an INTERACTIVE context — a Start-Menu click, the crash+reporter's *Restart SOLIDWORKS Design* button — succeeded, **with identical+arguments**.++**Working hypothesis (NOT proven):** the hidden/detached startup context — the+inherited `STARTUPINFO` show state, `CREATE_NO_WINDOW`, no console — stops the+launcher's Run thread from starting. The bridge is built so this hypothesis does+not have to be right: it tries routes in order, detects the stall from the+launcher's own log, cleans up, and moves on.++### FAILURE MODE B — no in-flight tracking (design bug)++`swx_start`'s hint said *"call swx_start again, which ATTACHES rather than+launching a second copy"*. There was no in-flight state anywhere in the code, so+every such call fired the shortcut again. **Four launcher chains accumulated on+Drew's desktop.** The return value was right and the effect was wrong.++### FAILURE MODE C — crash on first spawn++2026-09-03: `sldworks.exe` crashed on first spawn and the SOLIDWORKS crash+reporter appeared, with a **"Restart SOLIDWORKS Design"** button. Clicking it+launched successfully.++### FAILURE MODE D — hung, and exhausted++* **Hung:** Windows *Not Responding*, CPU idle, COM status timing out at 20s.+ The bridge cannot end it (policy, and the auto-mode classifier blocks it).+ Only the user can.+* **Exhausted:** 17.4 GB private, 21,770 handles, 52 documents open — and BOTH+ the part and the assembly stock templates returned NO document. The bridge+ reported it as a template problem, which sent the caller looking for a fault+ in a template that was fine.++### FAILURE MODE E — ownership lost (bug #5)++The ownership registry was memory-only, so a bridge restart disowned 23 open+crate documents, and a com-host respawn mid-build disowned an assembly created+ninety seconds earlier. See §5.++---++## 2. The state machine++`src/launch.rs`. One supervisor thread per attempt, started by `swx_start` and+outliving the HTTP request that started it — so a launch keeps making progress+whether or not anybody is polling, and `swx_start` is a pure OBSERVER of it.++```+ ┌──────────────────────────────── next route ◀─┐+ ▼ │+ invoke route ──▶ invoked ──▶ launcherStarted ──▶ runBegin ──▶ helperSignIn+ │ │ │+ no Run Begin in 20s │ ▼+ │ │ sldworksUp+ ▼ │ │+ STALLED ────────────┴──▶ no sldworks in 120s+ (kill OUR chain) │+ ▼+ attaching+ │+ COM attach within 240s+ ▼+ ATTACHED+```++| Stage | Evidence, measured |+|---|---|+| `invoked` | the com host ran the route |+| `launcherStarted` | a launcher log **created at/after our invoke** exists |+| `runBegin` | that log contains `Run Begin` |+| `helperSignIn` | a `SWXDesktopHelper` log created at/after our invoke |+| `sldworksUp` / `attaching` | `sldworks.exe` in the process census |+| `attached` | `start {launch:false}` succeeded — the only success |+| `stalled` | a deadline passed; OUR chain was ended; next route |+| `exhausted` | every route tried; ask the user |++**Routes, in order** (`launch::ROUTES`):++| # | Route | Mechanism | Why |+|---|---|---|---|+| 1 | `explorer` | `explorer.exe "<lnk>"` | the shell resolves and runs the link in its own normal show state — closest to a double-click |+| 2 | `shellExecute` | `ShellExecuteExW`, `SW_SHOWNORMAL`, on the com host's STA thread | an explicit show state, no shell-out at all |+| 3 | `powershell` | `Start-Process -FilePath '<lnk>'` | `Start-Process` does not pass on the caller's show state; a third independent mechanism |+| 4 | `cmdStart` | `cmd /c start "" "<lnk>"`, `DETACHED_PROCESS\|CREATE_NO_WINDOW` | **the route that stalled 5/5.** Kept LAST, not deleted: it is the one route proven to at least start the chain here |++The route that reaches `attached` is written to `launch-state.json` as+`preferredRoute` and tried FIRST next time, so a machine pays the fallback cost+once. A remembered route that is not a known route is ignored, never run.++**Budgets** (env overrides exist only so the suite can compress them):+`SWX_LAUNCH_RUN_BEGIN_SECS` 20 · `SWX_LAUNCH_SLDWORKS_SECS` 120 ·+`SWX_LAUNCH_ATTACH_SECS` 240 · `SWX_LAUNCH_INFLIGHT_SECS` 240.++### When SOLIDWORKS is up but COM will not attach++`attach_budget` expiring with `sldworks.exe` RUNNING is **not** a stall and the+chain is **not** killed: the application is on the user's screen, probably at a+3DEXPERIENCE sign-in only they can answer. The machine stops with+`outcome:"sldworksUpNotAttached"` and never tries another route — a second route+there would put a second SOLIDWORKS on somebody's desktop.++---++## 3. THE KILL ALLOWLIST++`launch::may_terminate`, one pure function, four conditions, **all** required:++1. the image is on `TERMINABLE_IMAGES` = **`CATSTART.exe`,+ `SWXDesktopLauncher.exe`** — and nothing else, ever;+2. the image is not on `NEVER_TERMINATE` (`SLDWORKS.exe`, `swCefSubProc.exe`,+ `explorer.exe`) or `NEVER_TERMINATE_PREFIXES` (**`3DEXPERIENCELauncher`**,+ matched as a prefix so a renamed or versioned daemon cannot slip through);+3. the creation time is **KNOWN** — an unreadable creation time is "not ours",+ never "probably ours";+4. it was created **at or after our own invoke** (±1s of clock slack), so a+ chain the user started before we asked for anything is never in the list.++Every terminate is `OpenProcess(PROCESS_TERMINATE)` + `TerminateProcess` **by+pid**, with the image name re-checked against a live census first (pids are+reused). There is **no image-name kill and no `/T` tree kill** anywhere in the+launch path.++### Never kill the 3DEXPERIENCE launcher daemons++`3DEXPERIENCELauncher.exe`, `3DEXPERIENCELauncherSysTray.exe`,+`3DEXPERIENCELauncherService.exe` are the Platform's own background services.+The bridge must never terminate one:++* they are **not required** for a launch — the successful launch on 2026-09-04+ ran with no Backbone at all, so killing them buys nothing;+* they carry the user's Platform session. Ending one is ending something that is+ not ours to end;+* **a real user launch rebuilds them.** If they are genuinely broken, the fix is+ the user starting SOLIDWORKS from their own Start Menu — not an agent+ reaching for `taskkill`.++`sldworks.exe` is likewise never killed here. Ending the user's CAD session is+`swx_kill`'s job, and that verb is gated behind the process-level write opt-in,+a `reason` and a `confirm` for exactly this reason.++---++## 4. In-flight idempotence++`launch-state.json`, next to `com-host-created.json` in the bridge's scratch+root, holds the attempt (id, started-at, shortcut, reason, caller, route plan,+current route, invoke time, chain pids, launcher log, stage, every route tried+with what it cost) plus `preferredRoute` and a bounded history.++`swx_start` during an attempt that is **unfinished**, **younger than 4 minutes**+and has **live evidence** (this process is supervising it, or its chain is+alive, or `sldworks.exe` is up) returns:++```json+{"success": true, "stillLaunching": true, "launchedNow": false,+ "stage": "runBegin", "ageSeconds": 7, "route": "explorer",+ "launch": { "...the whole attempt..." },+ "_hint": "NOTHING WAS LAUNCHED BY THIS CALL, deliberately: ..."}+```++and **launches nothing**. Two simultaneous `swx_start` calls are made safe by a+compare-and-swap on the supervisor flag: exactly one can win, the other reports.+The reason gate is checked AFTER the in-flight check, so polling with `swx_start`+— which this bridge's own hint tells callers to do — is never refused for a+missing `reason`.++The third condition is what keeps this honest rather than a timer: a state file+left behind by a bridge that was killed mid-launch does not block the next+launch forever.++`swx_status` carries the same `launch` block, plus a host-app half read+**without COM** — which is the point, because the moment those numbers matter is+the moment COM has stopped replying:++```json+"solidworks": {"processId": 88556, "responding": false, "privateMemoryMB": 17465,+ "handles": 21770, "openDocs": 52, "hung": true,+ "exhausted": "sldworks.exe is holding 17465 MB ..."}+```++`responding` is `IsHungAppWindow` on the application's own top-level window,+found by enumerating windows for its pid. `null` anywhere means UNKNOWN and is+never rendered as a healthy value.++---++## 5. Honest hung / exhausted reporting++| Code | Raised when | The hint says |+|---|---|---|+| `host_app_hung` | `IsHungAppWindow` is TRUE **and** a COM call timed out | only the user can end it, and why this bridge will not |+| `host_app_exhausted` | `NewDocument` returned nothing, **or** private memory > 12 GB, **or** > 40 documents open | close the bridge's own documents; then ask the user to restart; **do not retry in a loop** |+| `host_app_com_unreachable` | `sldworks.exe` is running, COM will not attach, and it is neither hung nor exhausted | probably a sign-in window; ask the user to look at their screen |++Both hung signals are required, and an unknown is never a yes: a not-responding+window mid-rebuild is not hung, and a COM timeout alone is usually a modal.+Likewise `exhaustion()` returns `None` when it has no reading — an unreadable+memory figure never manufactures a verdict.++**`swx_start` never launches a second SOLIDWORKS.** If `sldworks.exe` is running+at all, it reports one of the three codes above instead. On 2026-09-03 the+process was alive at 17.4 GB while COM attach failed, and the old bridge said+*"SOLIDWORKS is not running, and this edition cannot be started+programmatically"*.++### The empty-value audit++Every place a failed COM read could become a confident wrong fact:++| Where | Was | Now |+|---|---|---|+| `cmd_app_state` | raw `GetDocuments().as_array()` → `documents: []` beside a valid `processId`; the server then pruned its whole ownership registry | routed through `documents()`, which refuses an empty list `GetDocumentCount` contradicts (`docs_enumeration_failed`) |+| `cmd_status.openDocs` | `.unwrap_or(0)` — reported 0 with 51 open | `GetDocumentCount` is the authority; unreadable is `null`, never 0 |+| `cmd_check_recovery` | array length | the count, same reason |+| `cmd_close_all_documents` | array length before/after a DESTRUCTIVE call | `documents()` both sides |+| `cmd_import` before/after | array length; a collapse would import the file TWICE | `documents()` |+| `verbs3::ownership_context` | `Err` → `(None, [], None)` → prune wiped ownership | `prune_scratch` is a **no-op when the session is unknown** |+| `launchos::processes()` | — | `Err`, never an empty census: "I could not look" and "nothing is running" lead to opposite decisions |++---++## 6. Crash-reporter recovery++After `Run Begin` (before that there is no SOLIDWORKS to have crashed), the+supervisor looks for a top-level window whose title reads like a crash reporter+— and **candidacy is not permission**. It then asks ab, read-only, whether that+window carries a control named **"Restart SOLIDWORKS"**+(`desktop_find_control`). Only if ab says yes does it send ONE background+`desktop_ui_click` targeting that button **by accessible name**, with a+human-readable reason, and record it in the ledger as+`solidworks_crash_reporter_restarted`.++* **once per attempt**, guarded by persisted state;+* **the button is the identification**, not the title: a title is a guess and a+ click is an act;+* **nothing else is ever clicked.** No save prompts, no sign-in, no "Send report+ to Dassault", no OK on a dialog we did not identify. The test keeps a+ *"Save changes to Part1?"* dialog on screen throughout and asserts nothing+ touched it.++---++## 7. Bug #5 — ownership across a respawn++Two independent records, neither derived from the other, and now BOTH on disk:++* the **com host's** `com-host-created.json` (already persisted);+* the **server's** `owned-docs.json` — new. Written on every+ remember/forget/rename/prune, loaded by `Ctx::new`, keyed by the SOLIDWORKS+ process id AND, where the OS can tell us, that process's **creation time**, so+ a recycled pid cannot inherit another session's claims. When either start time+ is unknown, nothing is dropped on a guess: the reconcile can only ever REMOVE+ a claim, never add one.++Plus the other half, which was the actual field mechanism:+`prune_scratch(None, …)` used to drop **every** entry, because every entry fails+`d.session == None`. One unanswerable `app_state` therefore disowned everything+the bridge had created. It is now a no-op when the session is unknown.++`swx_stop` clears both records — the session ended, its untitled numbering+restarts at `Part1`, and no remembered title can be trusted.++The mock backend now also **persists the simulated SOLIDWORKS session** across+com-host respawns, because a real one survives them. Without that the mock+modelled a SOLIDWORKS that closes every document when the bridge's sidecar+restarts, which made this whole class of bug untestable: the documents were+gone, so no verb ever reached the ownership check.++---++## 8. Tests++`cargo test` — 186 tests, all green (97 unit + 14 adversary + 8 adversary2 + 4 adversary3 + 3 adversary4 + 28 e2e + 21 launch + 11 protocol), `x86_64-pc-windows-gnu` cross-compile clean.++`tests/launch.rs` (12) drives a **simulated machine** (`SWX_LAUNCH_FAKE_DIR`,+`src/launchos.rs::FakeOs`) whose behaviour is a written-down scenario, and+measures what the bridge DID on it — which processes it ended (`terminated.jsonl`),+how many launcher invocations it made (`invocations.jsonl`), what it left+running — never what it returned.++| Test | Proves |+|---|---|+| `a_stalled_route_is_cleaned_up_and_the_next_route_takes_over` | 2 invocations; ONLY our chain's 2 pids terminated; daemons, the user's pre-existing chain and `sldworks.exe` untouched |+| `the_route_that_worked_is_remembered_and_tried_first_next_time` | `preferredRoute` persisted, and a DIFFERENT server process attaches on ONE invocation with nothing to clean up |+| `three_calls_during_a_launch_start_exactly_zero_extra_launchers` | 5 `swx_start` calls → 1 invocation, 0 terminations; a bare call needs no `reason` |+| `every_route_exhausted_is_reported_honestly_and_leaves_nothing_of_ours_running` | 4 routes, 8 processes cleaned up, `ASK THE USER`, and a finished attempt does not lock out the next one |+| `solidworks_up_but_not_attaching_stops_the_machine_instead_of_launching_another` | `sldworks.exe` up + no COM: outcome `sldworksUpNotAttached`, no second route, chain NOT killed, route not remembered |+| `the_crash_reporters_restart_button_is_clicked_once_and_nothing_else_is` | exactly 1 click, by name, on the right hwnd; no window closed, no shell command, the Save-changes dialog untouched |+| `a_window_that_merely_looks_like_a_crash_reporter_is_never_clicked` | the button gate, not the title |+| `a_hung_solidworks_is_reported_as_hung_and_a_second_one_is_never_launched` | `host_app_hung`, 0 invocations, `responding:false` read without COM |+| `an_exhausted_solidworks_is_named_as_exhausted_rather_than_blamed_on_a_template` | `host_app_exhausted` quoting 17465 MB, 0 invocations |+| `a_new_document_that_comes_back_empty_is_exhaustion_not_a_template_fault` | the `NewDocument`-returns-nothing signature |+| `ownership_survives_an_unanswerable_probe_and_a_com_host_respawn` | ownership survives a blind window, a real `kill -9` of the com host, and a server restart |+| `a_bridge_that_cannot_see_the_machine_still_launches_rather_than_refusing` | an unavailable census is UNKNOWN, not "not running" |++Unit tests cover the pure rules: the kill list against daemons / pre-existing+chains / unknown creation times, the route plan, `is_hung`, `exhaustion`, the+crash-reporter matcher, stage ordering, the persisted registry round-trip and+pid-reuse reconcile, and the simulator itself.++---++## 8a. Round 2 (0.4.1) — what an adversary found in the above++An adversarial review of 0.4.0 wrote **14 failing tests**, each naming a property+this document claimed and showing the code did not have it. All 14 now pass, and+`tests/adversary.rs` is part of the suite. What changed:++### The kill list was scoped by CLOCK, not by identity++Two defects in one rule, both proved by test:++* a chain the **user** started while ours was in flight was swept up with ours+ (pids 70003/70004). Twenty seconds of a screen doing nothing is exactly when+ somebody reaches for their own Start Menu, and `clean_up_chain` re-derived its+ list from the census — ignoring the `Attempt.chain` it had carefully recorded;+* the "±1s of clock slack" was applied to the PROCESS's age, so a chain created+ a second **before** our invoke was terminable.++Now: a **baseline census is taken immediately before the invoke**; our chain is+what appeared after it and descends from our own `CATSTART` (`chain_candidates`,+a parent-pid fixed point); the cleanup kills only what is recorded, re-confirmed+as `(pid, image, creation time)`; and the kill itself **re-reads+`GetProcessTimes` on the open handle** immediately before `TerminateProcess`, so+a pid reused between the census and the kill cannot be hit. There is no backward+slack anywhere — instead `LaunchOs::now()` reads `GetSystemTimeAsFileTime`, the+same clock the creation times come from, through the same conversion. "Did this+launcher log appear after our invoke?" now prefers the launcher's **own filename+stamp** (`yyyyMMddHHmmss`) over the filesystem's creation time, which can be+absent, can be a copy time, and on Windows survives a delete-and-recreate.++### `documents()` believed an unreadable count++`count = GetDocumentCount.unwrap_or(-1); if docs.is_empty() && count <= 0 →+believe empty`. The guard written to close bug #5 had the same bug inside it: an+unreadable count is `-1`, and `-1 <= 0`. The count is now an `Option`, an empty+list is believed **only when the count says `Some(0)`**, and an unreadable one+retries then raises `docs_enumeration_failed`.++### The crash-reporter click could be triggered by an echo++`mentions_button` stringified ab's whole reply and searched it for the button+name — and the bridge **sends that name in the request**, so any reply echoing+its query passed the gate with zero controls found. It now reads **named fields+only**: an explicit `found:true`, or a control entry whose own `name` /+`automationId` / `text` names the button. An empty list is a no; an unrecognised+shape is a no.++Candidacy was `title contains "solidworks" AND one of seven words`, one of which+was the bare word `restart` — so a browser tab about a SOLIDWORKS crash, an+Outlook thread, and the Installation Manager's "restart required" were all+probed. It now requires a phrase that **says a crash happened** and rejects a+list of things that are demonstrably something else. On top of that, the window+must be **owned by the SOLIDWORKS process we are launching** (pid match), the+click is once per attempt **and** once per bridge process per sldworks pid, and++**crash recovery now ships DISABLED** (`SWX_CRASH_RECOVERY=on` arms it;+`launch.crashRecovery` reports `off`). The reason is in §9.5: no reply from ab's+`desktop_find_control` has ever been observed, so every rule above is written+against a guessed shape. `tools/launch-gate.py --probe-uia` captures the real+one, read-only, and clicks nothing.++### Exhaustion flagged a healthy session++`> 12 GB or > 40 documents` was read off one wedged session (17.4 GB / 52 docs)+and set 4 GB below it. The same machine the same week ran a **healthy 13.1 GB+session with 33 documents open** — and `swx_start` told it to close its+documents and have the user restart. Worse, `exhausted_error` relabelled+**every** sidecar error as exhaustion whenever the session was large, so a+template typo became "restart SOLIDWORKS".++Now: **`NewDocument` returning nothing is the only sufficient signal** — that is+what exhaustion looks like from the API and what was actually observed. The+compound path (`exhaustion_confirmed`) additionally needs *not responding* AND+*a real COM timeout* AND *memory rising across two reads*. Memory is reported as+`memoryAdvisory`, above 17.8 GB, worded as an observation; `privateMemoryMB` and+`workingSetMB` are reported **side by side**, because the two diverge by+gigabytes on a paged-out CAD session.++### The bridge's own long verb was reported as a hung SOLIDWORKS++`com_timed_out = com.error.is_some() || snap.busy` — and the com host is busy+exactly when **this bridge** is running a long verb, which is exactly when+SOLIDWORKS stops pumping its message loop. Two "independent" signals from one+cause. `is_hung` now returns `Option<bool>`: `com_timed_out` means *a COM call+exceeded its budget*, a busy host is `None`, and `hung:null` ships with a+`hungNote` saying why.++### A stale attempt was reported in flight forever++`in_flight` accepted **any** `sldworks.exe` as evidence that OUR attempt was+alive, so a bridge restarted while the user's SOLIDWORKS sat at a sign-in told+every caller to poll a supervisor that did not exist. The attempt now records+its **supervising bridge pid**; on load, an attempt whose supervisor is not us+and is not running is marked `outcome:"abandoned"` on the spot, and the only+liveness evidence accepted is **our own chain's pids**.++### A cleanup that could not run looked clean++`killed: []` from a failed census is indistinguishable from "there was nothing+to kill". `RouteTry` now carries `cleanupFailed`, `cleanupError` and+`chainLeftRunning`, the attempt surfaces them, and **the machine stops trying+further routes** once a cleanup could not be performed — going on would start+another chain beside one it cannot account for.++### Smaller, and all the same shape++* a route that started **nothing** was described as "reached SecurityContext";+ the note now branches on whether a launcher log for our invoke ever appeared.+* `finish("attached")` memorised the current route as `preferredRoute` even when+ the attach came from the **user** launching SOLIDWORKS mid-flight. It now+ requires `Run Begin` in OUR OWN log — the evidence that our route did the work.+* a retry after a failure re-swept all four routes, starting four fresh chains.+ There is now a **backoff** (`host_app_launch_backoff`, growing with+ consecutive failures) and a retry tries **one** route, not the plan.+* `host_app_health` took the **first** `sldworks.exe` in the census, so a zombie+ supplied the numbers and the verdict. All are reported; the one described is+ the one COM is attached to, else the newest, and `reportedProcessBasis` says+ which rule was used.+* `owned-docs.json` was a plain `fs::write` on every mutation (hundreds of times+ per build) and an unparseable file was silently an empty registry — bug #5's+ end state by another road. Writes are now **temp-file + rename** with a+ `.bak`; `Registry::{Absent, Loaded, Unreadable}` are three different answers+ and the corrupt case is loud on stdout and stderr and keeps the evidence as+ `.bad`. `launch-state.json` got the same treatment, and `swx_status` no longer+ rewrites it on every poll.+* `prune_scratch` compared paths **byte-exact** while `owns_doc` used+ `same_path`, so a document SOLIDWORKS reported under a different casing was+ pruned out of the registry that would have granted it. Both now call+ `verbs::doc_matches`.+* `component_count`'s `.unwrap_or(0)` was the success test for+ `swx_add_component`: a failed enumeration made a successful insert look failed+ and the method ladder **inserted again**. It is now an `Option`, and an+ unreadable count stops the ladder with `component_count_failed`.++### The remaining empty→confident collapses++| Where | Was | Now |+|---|---|---|+| `cmd_status.openDocs` | `as_array().len()` fallback | `GetDocumentCount` only; `null` when unreadable |+| `cmd_check_recovery` | `unwrap_or(0)` | `null` |+| `GetInterferenceCount` | `unwrap_or(0)` — a false **all-clear** on a clearance report | `null`, plus `interference_enumeration_failed` when the count and the list disagree |+| `swx_stop`'s unsaved guard | `unwrap_or(0)` waved the quit through | `unsaved_unknown`; an unanswerable question is not permission |+| `swx_state.blockingDialogs` | `unwrap_or_default()` → `[]` = "nothing in the way" | `null` + `blockingDialogsKnown` + `blockingDialogsError` |+| `swx_check_recovery` | ab unreachable → `recoveryPending:false` | `null` + `recoveryKnown` |+| `swx_close_window` | ab unreachable → `window_not_attributed` | `window_list_unavailable`, so the caller looks at the right problem |++## 8b. Round 3 (0.4.2) — the attribution law++A second adversarial pass wrote **8 more failing tests** (`tests/adversary2.rs`).+The verdict was blunt and correct: **0.4.1 still terminated a user-started+chain.** Proved on a run where our own route started nothing at all — the bridge+adopted pids 70003/70004, which belonged to the user's Start-Menu launch, and+ended them.++### THE LAW: no handle, no kill++Round 2 replaced "kill everything terminable younger than our invoke" with+"kill what descends from our own root". It did not say how the ROOT was known,+and the answer was a guess:++```rust+let is_root = p.is("CATSTART.exe") && ids.is_empty(); // 0.4.1+```++*The first CATSTART to appear after our invoke, in census order, is ours.* Three+things made that indefensible:++* census order is roughly pid order, and a pid does not record who asked for it;+* parentage could not break the tie, because `WATCHED_IMAGES` contained no+ shells — so the root's parent link always dangled and `descends` was false;+* on the `explorer` route, our CATSTART and a Start-Menu CATSTART have+ **literally the same parent**: the user's `explorer.exe`.++So the rule is now stated as a law, in `launch::AttributionRule`:++> **A process may be terminated by this bridge only if its membership of our+> launcher chain descends, through parent links, from a root the INVOKE ITSELF+> handed back. Nothing found by searching the machine afterwards is ever+> adopted.**++| Route | Attribution | May clean up? |+|---|---|---|+| `shellExecute` | `SEE_MASK_NOCLOSEPROCESS` → `hProcess` → `GetProcessId` + `GetProcessTimes` | **yes** |+| `powershell` | `Start-Process -PassThru` prints the pid; the child is waited on, and a non-zero exit or any stderr means the route started nothing | **yes** |+| `explorer` | none — the shell forwards the link to an existing process and returns nothing | **never** |+| `cmdStart` | none — `start` returns as soon as it has handed off | **never** |++Consequences, all of them deliberate:++* **default route order is now `shellExecute → powershell → explorer →+ cmdStart`** — attributable first. The unattributable two are kept because they+ may be the routes that actually work, which is still unmeasured.+* `chain_candidates` is a **descendant walk only**. With no seeds it returns+ empty; it can no longer invent a root. Seeds are used to find CHILDREN, never+ to re-adopt a member by its number — `attributed_chain` re-confirms every+ recorded member with `ProcInfo::same_process`, because CATSTART exits about+ three seconds into a healthy launch and its number is then free for the rest+ of a four-minute watch.+* an attribution with **no creation time is not an identity** and is refused: the+ bridge does not terminate a bare number.+* `explorer.exe` / `cmd.exe` / `powershell.exe` joined the census as **parent+ links only**, and the never-terminate list beside `sldworks.exe` and+ `3DEXPERIENCELauncher*`.+* a stall on an unattributable route is recorded as `cleanup:"notAttributable"`+ and says, in the response, that a chain may remain which the bridge cannot+ identify and the user may end from Task Manager.++### When the machine stops++A route that **started nothing** leaves nothing behind, so the machine goes on —+that is what keeps the unattributable routes worth trying. A route that started+something **it cannot account for** stops the machine, because another route+would put a second chain beside one nobody can find:++| Cleanup outcome | Continue? |+|---|---|+| `cleaned` / `nothingToClean` | yes |+| `notAttributable`, no launcher log ever appeared | yes — nothing was started |+| `notAttributable`, a chain WAS started | **no** |+| `failed` (census down, or a terminate refused) | **no** |++The baseline-census hole is closed with it: a baseline that could not be taken+is `Cleanup::blind()` — `cleanupFailed:true`, never "nothing to clean". The+route is still WATCHED (attach needs no census, and refusing to watch a launch+we just started is abandonment, not caution); it simply may not be followed by+another.++### The log evidence was a fabrication waiting to happen++Two defects, either of which would have made the gate's central measurement+meaningless:++* `launcher_logs().unwrap_or_default()` turned a **failed directory listing**+ into "there were no logs", and `is_newer_than` answered `(Some(_), None) =>+ true` — **any log is ours**. Five of the nine logs on the real machine contain+ `Run Begin`, so the bridge would have read a launch from three days ago, jumped+ straight to `runBegin`, skipped stall detection, and two minutes later reported+ a stall that never happened. A failed listing is now UNKNOWN: no log is read,+ no stage transitions, and the stall note says so instead of claiming nothing+ started. A **verifiably empty** directory is a different fact and is handled+ where we know the listing succeeded.+* `is_newer_than` short-circuited on the local-time filename stamp and never+ consulted the filesystem. For the one hour a year a local clock repeats, our+ own log carries a LOWER stamp than one written before it. It is now+ `stamp_newer || created_newer` — two independent clocks, either may answer yes+ — and a stamp the file's own UTC creation time contradicts by more than a+ timezone (26 h), or that is more than five minutes in the future, is+ **untrusted**: one log copied from another machine would otherwise poison a+ directory nothing prunes.++### Honesty and coupling++* `com_timed_out` was decided by `e.message.contains("did not answer")` in two+ files, a copy of a sentence built in a third. `SidecarError` and `HostError`+ now carry an explicit **`timed_out`** flag over the wire, and an+ **unrecognised** com failure yields `hung: null` — a crashed com host says+ nothing about whether SOLIDWORKS is wedged.+* `swx_interference`'s hint now says what `count: null` means, in the terms that+ matter: *null is falsy in every language this bridge's callers use, so+ `if (!r.count)` turns "I do not know" into "all clear" on a clearance report+ somebody builds a physical part from.* Branch on `countKnown`.+* the mock can now emit all three new nulls (interference count,+ `check_recovery`'s open-document count, `component_count`), so those shapes are+ exercised end to end rather than merely produced.+* `write_atomic` **retries the rename three times** on ACCESS_DENIED — an+ antivirus scanner or the search indexer holds a brief handle on a file it has+ just seen created, and this one is rewritten hundreds of times per build — and+ returns whether the write landed; a failure is logged, not swallowed.+* an unreadable `owned-docs.json` now **falls back to the `.bak` generation**,+ loudly. It is a registry this bridge wrote one write ago: at worst it is+ missing the newest claim, where owning nothing refuses every document created+ before the restart, which is bug #5 itself.++### Crash recovery stays OFF++Unchanged from round 2, and now with a second reason: **the crash reporter is a+separate process from `sldworks.exe`** (Windows Error Reporting hosts it), so+the pid attribution added in round 2 must accept a child or otherwise related+process rather than an exact match. Writing that rule needs the real+relationship, which nobody has looked at. `tools/launch-gate.py --probe-uia`+now captures both the `desktop_find_control` reply shape **and** every+crash-reporter-looking window's owner pid beside `sldworks.exe`'s.++## 8c. Round 4 (0.4.3) — the law, applied to evidence++Adversary pass 3 found **no consent-affecting defect**: it could not make the+bridge terminate a process it did not start. The law held at the mechanism, and+`the_users_own_launcher_chain_is_still_never_terminated` is pinned as a+regression test so it cannot quietly re-open.++What it found instead is the same question one level up. **The bridge no longer+adopts a stranger's processes — it still adopted a stranger's evidence.**++### A new log is not our log (H1)++`watch_route` attributed a launcher log on ONE test: is it newer than the newest+log that existed before our invoke? That answers *"this log is not+pre-existing"*. It does not answer *"we wrote it"* — and nothing else was asked.++So on a run where our route started nothing (which this bridge detects, names,+and rightly keeps going after), a launch the USER starts while we watch writes+the log we then read as our own. Their `Run Begin` becomes our `runBeginSeen`,+and `finish("attached")` — whose whole purpose since round 2 was to stop a+mid-flight user launch teaching us the wrong lesson — then writes+`preferredRoute` for a route that started **nothing**. Permanently, on that+machine, first in the plan for every future cold start. It is also the first+thing the gate would have measured, so the measurement would have been a+fabrication.++The same law now governs the evidence:++> A launcher log is ours only when OUR OWN attributed chain existed to have+> written it: an attribution exists, its root has been CONFIRMED alive at least+> once, and the root was created no later than the log.++On an unattributable route **no log can be attributed at all**, so+`run_begin_seen` stays false there and `preferredRoute` is never written from+it. A new log we cannot claim is reported as `unattributedLog: true` and+ledgered — evidence that something started, no evidence whose it is.++**The cost, stated plainly:** if the launcher only ever comes up on `explorer`+or `cmdStart`, the bridge will launch SOLIDWORKS successfully and still never+learn that route. `attached` is proof (from COM) that SOLIDWORKS is up and+usable; it is not proof that our route did it. The plan order then stays fixed,+which is the right way round — a remembered route is acted on for ever.++### A root we cannot see still carries its children (H2)++`attributed_chain` confirmed the root by finding it in the census — and the+census holds only the watched images. `ShellExecuteExW` hands back whatever the+shell created for the `.lnk`, and **no real reply has ever been observed**. If+that process is a shim, a wrapper, or a future Platform build rather than+`CATSTART.exe`, the root was never seeded, the walk had nothing to descend from,+and the real CATSTART it started — whose `ppid` is the handle-proven pid, right+there in the census — was never attributed. The stall was then reported as+`attributed:true, cleanup:"nothingToClean"`: a CLEAN recovery, with a chain of+ours still running.++A handle-proven root is **proof of lineage whether or not the root is itself+something we may terminate**. It now seeds the walk unconditionally — unless the+machine CONTRADICTS it (a census entry with that pid and a different creation+time, i.e. the number has been reused), in which case the lineage is void and+its apparent children are not adopted.++And "never confirmed" is now its own outcome. An attributed route whose root and+descendants were never once seen reports `cleanup:"notConfirmed"`,+`cleanupFailed:true`, `chainMayRemain:true` and stops the machine — that is a+failure to account for what we started, not a clean "nothing to kill".++| `cleanup` | Means | Machine continues? |+|---|---|---|+| `cleaned` | our chain was found and ended | yes |+| `nothingToClean` | attributed, confirmed, nothing left running | yes |+| `notAttributable` | the route cannot say what it started | only if NO log appeared at all |+| `notConfirmed` | attributed, and we never saw the root or a descendant | **no** |+| `failed` | census down, or a terminate refused | **no** |++### Also in this round++* **The simulator can now model "attributable route, chain started, NO+ handle"** (`"attributable": false` per route) — the shape `ShellExecuteExW` on+ a `.lnk` is most likely to produce, since the shell may hand the link to an+ already-running process by DDE and return nothing. And `"invisibleChain":+ true` models a chain the census never shows, which is how `notConfirmed` is+ tested.+* **A real concurrency bug, found by the suite flaking.** `write_atomic`'s temp+ file was named from the pid alone — one name shared by every HTTP worker+ thread, and the ownership registry is rewritten from several of them. Two+ concurrent writes raced on that one file: both wrote it, one renamed it away,+ the other's rename failed with "no such file", and a half-written file could+ have been renamed into place. The name is now unique per write.+* **The gate answers "which world are we in?" first** (`--which-world`): does+ route `shellExecute` come back with a `rootPid` on this machine? That single+ answer decides whether this bridge can ever clean up after itself here, and+ everything else in the run is read differently depending on it. The gate also+ prints a per-route table — invoked-at, the log it matched (or `unattributed`),+ Run Begin, sldworks, attach, cleanup kind.++## 8d. Round 5 (0.4.4) — evidence bookkeeping++Adversary pass 4 found **no consent-affecting defect** either: the process-side+law has now survived four rounds of direct attack. What it found is the last+version of the same mistake — a test that is NECESSARY and not SUFFICIENT.++### "Our chain existed before this log" is true of every log after that (H1)++Round 4's rule was `root_confirmed && root.created_at <= written`. That is true+of **every** log written after our chain started, the user's included — and+`launcher_logs()` is newest-first while the selection was a bare `find`, so when+a second launch is running the bridge preferentially read THEIRS. Our own+route's log stops at `SecurityContext:`; theirs has `Run Begin` in it; the bridge+reported that it had seen `Run Begin`, and the round-3 `run_begin_seen` guard on+the route memory was defeated for the second time.++The log is now chosen by three tests, in order of how much they prove+(`choose_our_log`):++1. **AUTHORSHIP.** The launcher stamps the writing thread's id on every line+ (`17:23:09 [116244] info Run Begin`). If `thread_owner(tid)` resolves to a+ process in OUR OWN chain, the log is ours — positively. A miss proves+ nothing, because threads exit.+2. **SOLE CANDIDATE, bounded from both sides.** Exactly one log post-dates the+ baseline, our chain existed before it, **and** no launcher chain that is not+ ours was seen alive in the meantime (`foreignChainSeen`).+3. **Otherwise: none.** Two candidates and no authorship proof is+ `unattributedLog`. Never "pick the newest".++Once a log is ours it STAYS ours — re-selecting every poll is how a log that+appears later displaces the one we were reading.++### Every per-route fact resets per route (M1)++`root_confirmed` and `unattributed_log` did not, with two consequences:+`notConfirmed` was **unreachable after route 1** (so round 4's H2 was quietly+restored for routes 2-4), and the log gate's root-confirmed half was+pre-satisfied by an EARLIER route's chain. The simulator gained+`"rootVisible": false` so the invisible-root case has a test of its own.++### A per-route table whose rows are all the same number (G1)++`launcherLog`, `runBeginSeen` and `unattributedLog` lived only on the ATTEMPT,+and the gate's per-route loop read all three from there — printing the last+route's evidence against every row. `RouteTry` now carries its own+`launcherLog` / `runBeginSeen` / `unattributedLog` / `rootPid` /+`rootConfirmed` / `cleanup`, copied at the end of the route while they still+describe it, and the gate prints from `routesTried` only. The winning route is+recorded too, so the table's most important row is no longer missing.++### The gate could report PASS without seeing the machine (G2)++`verb()` returned `{}` and `ps()` returned `""` on failure — the same+empty-to-confident collapse the bridge itself was rewritten four times to stop+making, on the Python side, in the tool whose whole job is measurement. Both now+raise `Unreadable`, and the gate exits **3** with "could not ask, not nothing+found". `--which-world` is renamed `--probe-handle-launches` so the name says it+LAUNCHES, and `ps()` documents that `shell_execute` is ab's general escape hatch+whose only assurance is reading the script (this one runs a process census, reads+the launcher's own log directory, and curls the bridge's loopback port — no kill,+no write outside `$env:TEMP`).++### Also++* **`lastRouteBeforeAttach`** records which route was in flight when COM+ attached, with `"proof": false` — an ordering hint for a human. It never+ satisfies `run_begin_seen` and is never promoted to `preferredRoute`. Added+ only after H1 was fixed, so it cannot be persisted from a corrupted signal.+* `finish()` is **idempotent** — a second call was bumping+ `consecutive_failures` again, doubling the backoff for one attempt.+* `ledger.jsonl`, `timings.json`, `settings.json` and the com host's+ `com-host-created.json` now go through `write_atomic` too. The ledger is the+ one artefact that outlives the bridge; a truncating write could have taken the+ whole audit log rather than the last line. The `.bak` copy is staged through a+ unique name and renamed into place, so two writers cannot leave a torn backup —+ which matters because the backup is what `owned::restore` falls back to.+* the descendant walk requires `child.created_at >= root.created_at`: a process+ that already existed when ours started is not our child, whatever its ppid+ points at.+* the `created().or(modified())` conflation in the log listing is now commented+ where it is: `modified()` on a live log is "a moment ago" whatever the launch+ is doing, so it is a last resort for filesystems that record no creation time.++## 9. STILL UNPROVEN until the live gate runs++Nothing below has touched a real SOLIDWORKS. `tools/launch-gate.py` is written+to settle every line of it, and **must only be run when Drew says SOLIDWORKS is+down.**++1. **Whether any of `shellExecute` / `powershell` / `explorer` actually reaches+ `Run Begin`.** The stall hypothesis (startup context / show state) is+ inference from 5 stalls and N interactive successes, not a proven cause. If+ all four routes stall, the machine will say so honestly (`exhausted`,+ *ask the user*) — which is the correct behaviour, and would tell us the+ hypothesis is wrong.+2. **The route ordering.** It is now decided by ATTRIBUTABILITY, not by which+ mechanism is most likely to work — `shellExecute` and `powershell` lead+ because they can say what they started. If the gate shows the launcher only+ comes up on `explorer` or `cmdStart`, the bridge will launch successfully and+ still refuse to clean up after those routes; that is the trade, and it is the+ right way round.+3. **That killing a stalled `CATSTART`/`SWXDesktopLauncher` is clean** — that it+ leaves no dialog, no half-registered session and no Platform complaint.+4. **Creation-time resolution.** `GetProcessTimes` is second-resolution, and+ there is no longer any slack: a chain created in the same second as our+ invoke passes, one created a second earlier does not. If a real launch is+ ever missed for that reason it shows up as an empty `chainPids` and a+ cleanup with nothing to do — visible, and safe in the direction that matters.+ The `ppid` walk and the baseline diff are what actually carry the attribution.+5. **ab's `desktop_find_control` reply shape has never been seen**, and the+ whole click decision is read from it. Crash recovery therefore **ships+ disabled**; `tools/launch-gate.py --probe-uia` captures a real reply,+ read-only, and until then the bridge reports the crash reporter and does+ nothing about it. The button is matched as *"Restart SOLIDWORKS"* (a+ `contains`) because Drew reported the label as "Restart SOLIDWORKS Design";+ `SWX_CRASH_REPORTER_TITLE` widens the window matcher without a rebuild.+6. **`IsHungAppWindow` against a genuinely wedged SOLIDWORKS**, and the+ `PrivateUsage` figure against Task Manager's.+7. **The desktop (`Com`) launch route** still has no in-flight guard of its own.+ It is synchronous inside the com host, so concurrent calls queue on the+ request mutex and the second one attaches — but that is a property of the+ mutex, not something this release tested.++## 10. Running the gate++```bash+tools/launch-gate.py --baseline # read-only: print the machine's baseline+tools/launch-gate.py # the full gate — STARTS SOLIDWORKS+```++It never stops, kills or foregrounds anything. A failed probe is reported as+UNKNOWN and the claim it would have supported is marked NOT PROVEN, rather than+passing by default.++## 11. What the gate will tell us — one screen++Run `tools/launch-gate.py` with SOLIDWORKS down. It exits 0 on pass, 1 on a failed claim, 2 if SOLIDWORKS is already running, and **3 if any probe could not be read** — the last is 'could not ask', and nothing below it is proven. Six questions, in the order the+run answers them, and what each answer means:++| # | Question | If YES | If NO |+|---|---|---|---|+| 1 | **Does `shellExecute` hand back a `rootPid`?** (`--which-world`) | the bridge can clean up after itself on this machine; the whole attribution design is live | it never terminates anything here: it stops after one route and reports that a chain may remain. Designed behaviour, but stall recovery becomes reporting only |+| 2 | **Does any route reach `Run Begin`?** | the stall hypothesis (startup context / show state) is confirmed, and route order can be tuned to the winner | all four stall: the hypothesis is WRONG, the cause is elsewhere, and the honest answer is the one the bridge already gives — ask the user to launch it |+| 3 | **Which route wins?** | `preferredRoute` is written and the next cold start costs one invocation | if the winner is `explorer` or `cmdStart`, the launch works and is NEVER remembered (§8c) — expect the same fallback cost every time |+| 4 | **Exactly one new chain, and three no-op `swx_start` calls during flight?** | in-flight idempotence holds on the real machine | the four-chain failure is not fixed; stop and re-open failure mode B |+| 5 | **Is every pre-existing process still alive at the end?** | the kill allowlist and the attribution law hold under real timing | a consent failure: stop, do not deploy, re-open round 1 |+| 6 | **What shape does `desktop_find_control` really return, and what pid owns the crash reporter?** (`--probe-uia`) | `mentions_button` can be written against a real reply and crash recovery can be armed | it stays `off`, which is where it is today |++Timings the run prints, all measured rather than assumed: chain-appeared,+`Run Begin`, `sldworks.exe`, COM attach — plus, per route, the log it matched+(or `unattributed`) and the cleanup kind.++**What the gate CANNOT tell us**, and must not be read as telling us: whether+the launcher stalls for the reason we think (it can only show that a route works+or does not), and whether crash recovery is safe (that needs a real crash, and+one has not been reproduced on demand).++## Live proof 1 — ownership survives a com-host respawn (2026-09-08, 0.4.4 on Drew's laptop)++Sequence, measured by effect on a real SOLIDWORKS Connected session (pid 107688, user-launched, empty):++| step | call | effect |+|---|---|---|+| deploy | 0.4.4 installed over the dev bridge | installed bytes 1,601,024 = built; `bridgeVersion 0.4.4`, `userWrites disabled`, `crashRecovery off` |+| create | `swx_new_document {type:part}` | `Part1` open, unsaved, `openDocs 1` |+| respawn | `process_kill` of the bridge's own com host (pid 15640) | new host pid 73008, still attached, `openDocs 1` |+| close | `swx_close_doc {targetDocument:"Part1", save:false}` in the DEFAULT posture | `success:true, closed:"Part1"`, `openDocs 0` |++On 0.3.3 the same close after a respawn was refused with "this bridge did not create it" (bug #5/#12), because+the in-memory registry died with the host and a null document list pruned what was left. 0.4.4 keeps the+registry in `owned-docs.json` keyed to the SOLIDWORKS process and treats an unreadable list as unknown.++Still unproven live: the launch routes (needs SOLIDWORKS closed), and a graceful quit — the+`Process.CloseMainWindow()` experiment was blocked by the agent-side auto-mode classifier, see+docs/QUIT-INVESTIGATION.md.
docs/QUIT-INVESTIGATION.mdadded+1501@@ -0,0 +1,1501 @@+# Quitting SOLIDWORKS Connected, gracefully — the investigation++> Drew, 2026-09-08: *"it is critical that we get this right."*++**Question.** How can the bridge reliably and gracefully QUIT *SOLIDWORKS Design+Professional for Makers 2026* (a SOLIDWORKS Connected / 3DEXPERIENCE R2026x+edition) when it has **zero open documents** — and never by killing the process?++**Scope of this pass.** Research and design only. Nothing on Drew's desktop was+started, stopped, closed, clicked or foregrounded to produce this document; every+desktop probe below is a read (process census, window attributes, log files,+registry values, and two read-only bridge verbs). SOLIDWORKS was up and in use+throughout — `openDocs 5` at the time of writing — so the experiment in §5 has+**not** been run. It is written to be run later, on a bridge-launched, 0-document+session, with Drew's go-ahead.++---++## Executive summary — the ten lines that matter++1. **The most likely working route is a ladder, and its first rung is not what we+ have today.** `UserControl = false` → `ExitApp()` → **release every COM+ reference we hold** (v1 does neither the first nor the last), verified by the+ process census rather than by `GetActiveObject`.+2. **v1 cannot tell "declined" from "quit but pinned by our own pointer."** The+ com host holds a live `IDispatch` on SOLIDWORKS at the exact moment it asks+ whether SOLIDWORKS went away, and the documented meaning of `UserControl` is+ that an automation client's outstanding reference is what keeps the app alive.+ Some fraction of the observed decline may be self-inflicted.+3. **If `ExitApp` really is declined at 0 documents, the strongest evidence points+ at `WM_CLOSE` to the main frame.** This machine's previous session ended at+ 15:24:05 with the journal recording `swApp.ExitApp` + `End Sub` and the+ 3DEXPERIENCE helper logging `Disconnect` — i.e. the application's own+ documented exit path ran to completion — and that shutdown is the one Drew+ attributes to a window-manager close.+4. **Three untried rungs sit around those**, and nobody in the public ecosystem+ uses any of them: `RunCommand(swCommands_App_Exit = 2825)` (File ▸ Exit inside+ the app's own dispatcher); `RunMacro2` of a two-line shipped `.swp` that calls+ `ExitApp` **from inside** the process (this edition restricts add-ins but runs+ macros, and the VBA host is already loaded); and the **Restart Manager**+ (`RmShutdown` with `RmForceShutdown` omitted) — the OS's own documented polite+ shutdown, targeted by pid **and creation time**, which never terminates anything+ and returns `ERROR_FAIL_SHUTDOWN` with a per-process reason when refused. It goes+ **last**, because `ENDSESSION_CLOSEAPP` tells an app to save without prompting,+ and a rung whose failure mode is a silent save must sit below the rungs whose+ failure mode is a dialog we can see.+5. **A graceful quit is measurable without trusting SOLIDWORKS at all**: the pid+ *and its creation time* gone; the per-session children (`sldProcMon`,+ `sldsurfacing`, `sldimport`, `swCefSubProc` ×10) reaped; the tray/daemon set+ (`ENOPLMCSAClient`, `EdmServerV6`, every `3DEXPERIENCELauncher*`) **still alive,+ because those survive a graceful exit by design**; `swxJRNL.swj` ending in+ `swApp.ExitApp` + `End Sub` and its lock released; no new top-level window; no+ Error Report dialog.+6. **`%TEMP%\swx<pid>` leftovers are NOT an unclean-exit signal** — four dead+ sessions left theirs behind, including the one that exited cleanly. Ruled out+ as evidence before anyone builds a check on it.+7. **The precondition rule in the code today is the wrong one.** `swx_stop` gates+ on `unsavedDocuments == 0`, not on the open-document count — so right now, with+ **five of Drew's clean documents open**, it would try to quit. v2 requires+ `openDocuments == Some(0)`, and `force` may waive only the unsaved question,+ never the count, the dialog census, busy, hung or identity.+8. **No rung ever answers a dialog.** A save prompt (including Connected's *Save+ all to This PC / Save all to 3DEXPERIENCE / Don't Save / Cancel*) stops the+ ladder and is reported verbatim; so does an unrecognised window, a licence+ dialog, and the hang-triggered *Status Feedback* telemetry modal. And no rung+ ever escalates to a kill: the ladder ends at (f), `RmForceShutdown` is+ unreachable from the code, and there is no rung (g). (Vendor sources settle one+ worry: **there is no sign-out prompt on exit**, and closing SOLIDWORKS does not+ log the user out of the platform.)+9. **The bridge is the right place for this and the classifier is right to refuse+ the shell.** A `Process.CloseMainWindow()` from the agent's shell has no+ document count, no dialog census, no attributed identity, no consent record and+ no audit; `swx_stop` has all five, refuses on any unknown, and writes a ledger+ line per attempt — including the refusals v1 records nowhere.+10. **Biggest unknown:** whether `ExitApp` is genuinely declined on a *verified*+ zero-document session at all. The ledger on this machine contains **no** stop+ event, so the decline we can evidence came from a session with a dirty `Part1`+ open (live run 3, 0.3.0) — which is a save-prompt stall, a different failure+ with a different fix. Everything downstream of that question is a ladder+ designed for a fact that has not been measured yet, and §5.7 measures it+ first.++---++## 0. What this machine actually says (measured 2026-09-08, read-only)++This section exists because most of the folklore about quitting SOLIDWORKS is+about the classic desktop product, and almost none of it is about a Connected+edition with no add-ins and a platform licence. These are facts from Drew's+machine, not inferences.++### 0.1 The session, and who started it++| Fact | Value | How it was read |+|---|---|---|+| `sldworks.exe` | pid **107688**, started 15:35:41, parent 15536 (gone) | `Win32_Process` |+| Its image | `C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\sldworks.exe` | `Win32_Process.ExecutablePath` |+| Command line | *(no arguments — no `-Embedding`, so this is a UI session, not a COM-activated one)* | `Win32_Process.CommandLine` |+| Who launched it | **the bridge**, via the platform shortcut | ledger `solidworks_launched … route:"platformShortcut"` at 15:35:31 |+| Edition | `connected`, version 34.3.0, `canLaunchProgrammatically:false` | `swx_status` |+| Open documents **now** | **5**, all clean (`unsavedDocuments 0`), active = Drew's `Composer_validation.SLDASM` | `swx_get_app_state` |+| Main frame | hwnd **6622700**, class `Afx:0000000140000000:8:…`, visible, enabled, **not** hung | `EnumWindows` + `IsHungAppWindow` |+| `SC_CLOSE` on that frame | `GetMenuState(GetSystemMenu(hwnd), 0xF060) = 0x20001000` — **MF_GRAYED/MF_DISABLED clear, so Close is ENABLED** | `GetMenuState` |+| Blocking dialogs | **none** — no `#32770` top-level window in the process at all | window census |+| Other windows in the process | the VBA host (`wndclass_desked_gsk`, "Microsoft Visual Basic for Applications", hidden) + `NVOpenGLPbuffer`, `PbrsHost`, `ThunderMain`, `MsoStdCompMgr`, tooltips | window census |++Two things follow immediately. The frame's Close item is enabled, so the+window-manager rungs in §5 are *available* on this app right now (that was not a+given — an app can grey out SC_CLOSE while modal). And the VBA host is loaded,+which makes the `RunMacro2` rung in §5 real rather than theoretical.++### 0.2 The 3DEXPERIENCE processes that are alive *with* SOLIDWORKS++Everything with a Dassault image path, at rest, with 5 documents open:++```+107688 sldworks.exe <- the app+117996 sldProcMon.exe ) SOLIDWORKS' own out-of-process+101704 sldsurfacing.exe ) servers, started by sldworks.exe+121600 sldimport.exe )+ 68248 ViewServerV6.exe ) 3DEXPERIENCE-side servers. EdmServerV6 ++ 34020 EdmServerV6.exe ) ENOPLMCSAClient are the "PLM Services"+123724 ENOPLMCSAClient.exe ) tray apps and SURVIVE a graceful exit+10 x swCefSubProc.exe <- the CEF (browser) subprocesses+ 90552 3DEXPERIENCELauncherSysTray.exe <- PLATFORM daemon: NOT ours, never touch+ 6076 3DEXPERIENCELauncher.exe <- PLATFORM service: NOT ours, never touch+```++**No `SWXDesktopLauncher.exe`, no `CATSTART.exe`, and no `SWXDesktopHelper.exe`+are running.** The launcher chain exits the moment it has spawned `sldworks.exe`+(its log ends `Launched successfully ExitCode: 0` … `Run End`), so there is no+launcher process to notify, and nothing of the launch chain to reap on the way+out.++That gives the single most useful measurement in this whole document: **a+graceful quit should reap the per-session server processes** (`sldProcMon`,+`sldsurfacing`, `sldimport`, `swCefSubProc` ×10), while the tray/daemon set must+survive untouched: `3DEXPERIENCELauncher*`, and — this is documented, see §2.6 —+`ENOPLMCSAClient.exe` and `EdmServerV6.exe`, which are the *3DEXPERIENCE PLM+Services* tray apps and are **supposed** to outlive SOLIDWORKS. (`ViewServerV6.exe`+is undocumented either way: record what it does, conclude nothing.) A kill would+leave the first group orphaned. So "did it quit *cleanly*" is answerable from the+process census alone, without trusting anything SOLIDWORKS returned — provided the+expected survivors are not mistaken for leaks.++### 0.3 How the PREVIOUS session ended — the best evidence we have++The session before this one ran from 2026-09-04 17:23 to **2026-09-08 15:24**,+and three independent artefacts agree on the instant it ended:++| Artefact | What it says |+|---|---|+| `%APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\swxJRNL.BAK` (147 KB, 15:24:05) | line 2933 `swApp.ExitApp`, line 2934 `End Sub` — **the only `ExitApp` in the file, and the journal closed itself** |+| `%APPDATA%\DassaultSystemes\SWXDesktopHelper\20260904172313_….log` (15:24:08) | a 30-second licence heartbeat (`Offline License Count 0`) up to 15:23:53, then one final line: `15:24:04 info Disconnect` |+| `%TEMP%\swx98960\` | left behind, last written 15:24:04 |++Read that carefully, because it is the closest thing we have to a proven graceful+quit on this edition:++* SOLIDWORKS' journal records **UI-driven** commands as their VBA equivalents+ (API calls the bridge makes are *not* journaled — the current session has 5+ bridge-opened documents and its journal never mentions them). So+ `swApp.ExitApp` at the end of that file is the **application's own exit code+ path running** — the same path File ▸ Exit runs — and `End Sub` after it means+ the journal was **closed properly**, i.e. the process was not killed.+* The 3DEXPERIENCE helper logged `Disconnect` and stopped. The platform side was+ released, in order, one second before the journal closed.+* And the per-session temp directory **survived a clean exit**. `%TEMP%\swx<pid>`+ leftovers exist for four dead sessions (`swx13460`, `swx98000`, `swx88556`,+ `swx98960`) — so a leftover temp dir is **not** evidence of an unclean exit and+ must not be used as one.++That 15:24 shutdown is the event Drew describes as *"a stuck modal was closed via+the window manager and the whole app died"*. If that attribution is right, then+**a window-manager close on this edition ran the full documented exit path**+(journal `ExitApp` + `End Sub`, helper `Disconnect`) — which makes rung (c) in §5+the most promising route, not the most dangerous one. It is stated here as the+leading reading of the evidence and **not** as a proven fact: nothing recorded+which gesture produced that shutdown.++### 0.4 The licence heartbeat is NOT running in the current session++The previous session had a `SWXDesktopHelper` writing a licence heartbeat every+30 s. **This one does not**: today's helper logs+(`20260908153544_….log` and `…_dslcc.log`) are **0 bytes**, no helper process+exists, and today's launcher log says++```+15:35:41 info Started from desktop shortcut skipping PLMCSA client start+```++The only platform record of this session is+`%APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\3dexperience-log.json`, which contains one+`Login` operation (`loginEx`, `setsecuritycontext`, `getcastransientinfo`,+6.1 s, 20:36:51Z) for pid 107688, tenant `OI000616940`, and nothing since.++Consequence for the experiment: **do not expect a helper `Disconnect` line from a+bridge-launched session** — there is no helper to write one. The platform-side+signal to watch is instead whether `3dexperience-log.json` gains a logout /+disconnect operation. **Not** the exit of `EdmServerV6` / `ENOPLMCSAClient` — those+are the PLM Services tray apps and are documented to stay (§2.6).++### 0.5 The ledger has no `stop` event of any kind++`%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\ledger.jsonl` (637 bytes,+the only ledger on the machine — both the installed bridge on port 49861 and the+dev bridge on 24695 share this scratch root) contains exactly three entries:++```+solidworks_launched 15:35:31 route platformShortcut, edition connected+document_created 16:14:10 Part1+document_closed 16:15:00 Part1, by close_doc, bridgeCreated:true+```++There is **no `solidworks_stop_declined` and no `solidworks_stopped`** line. So+the reported "`ExitApp` is declined even with 0 documents" observation did **not+come from `swx_stop`** on this machine (that path always records a ledger line —+`src/verbs.rs:1078`). The measured, ledger-backed decline we do have is live run 3+on 0.3.0, `swx_stop {force:true}` → `success:true, stopped:false`, and that+session had a dirty `Part1` open (`docs/FUSION-PARITY-BRIEF.md`, §"The gate was+closed in the wrong direction").++**This is not a quibble, it is the first thing the experiment must settle.** "0+documents and `ExitApp` still declined" and "documents open, so SOLIDWORKS raised+a save prompt and the quit stalled" call for completely different ladders. §5+therefore measures rung (a) *first*, on a genuinely `Some(0)` session, and+records the result in the ledger.++---++## 1. The API semantics of quitting++Sourcing note: `help.solidworks.com` serves a JavaScript shell to every fetcher we+have (verified again today — the 2026 `ExitApp` page returns navigation and no+content), so official wording below is quoted from search-result snippets of those+same pages and is marked as such. Forum wording is quoted from the pages+themselves. Where a claim is folklore, it is labelled folklore.++### 1.1 `ISldWorks::ExitApp`++Official help (`…api/sldworksapi/…ISldWorks~ExitApp.html`, via snippet):++> "This method is not normally used with macros (*.swp) because it shuts down your+> SOLIDWORKS session." … "Calling this method ensures that no SOLIDWORKS processes+> are left running when your program ends."++Three things the documentation does **not** say, and all three matter:++* it does not document a **return value** that reports refusal (it is a `void`+ in the COM sense — our host reads success from the absence of an HRESULT+ failure, which is why v1 has to verify separately);+* it does not document any **precondition** — no "close documents first", no+ "UserControl must be false";+* it says **nothing about SOLIDWORKS Connected**, and `ExitApp` appears on no+ published discontinued-for-Connected list (§4.4).++So "declined" is not a documented state. What *is* documented, repeatedly, is that+the surrounding COM ownership decides whether the process actually goes away.++### 1.2 `ISldWorks::UserControl` — the flag that decides who owns the lifetime++Official help (`…ISldWorks~UserControl.html`, via snippet):++> "If the SOLIDWORKS application is started by your program, then the SOLIDWORKS+> application closes when your program ends. However, if you pass control of the+> SOLIDWORKS application to the end-user by setting the UserControl property to+> true, then it remains running after your program ends."++That is the whole mechanism. `UserControl = true` means *"this session outlives its+automation client"*, and it is what every ecosystem project sets (SolidPilot sets+it explicitly; the pywin32 recipes all recommend it) precisely so their scripts do+not take the user's SOLIDWORKS down with them.++Now apply it to our situation. **This session was launched by the bridge via a+shortcut**, so `sldworks.exe` runs with **no `-Embedding`** (measured, §0.1): it is+a normal interactive session that we later *attached* to with+`GetActiveObject`, not a COM-activated one we own. In that arrangement:++* SOLIDWORKS is the user's session by construction; the bridge is a guest;+* our com host holds a live `IDispatch` (`Disp(pub IDispatch)`, `#[derive(Clone)]`+ — a refcounted handle: clone is AddRef, drop is Release) for the whole duration+ of the `"stop"` call, including across the verification;+* the widely-repeated remedy for "`ExitApp` did nothing" is therefore not a+ different call, it is **releasing the reference**: the canonical answer is+ `ExitApp()` followed by `Marshal.FinalReleaseComObject(swApp)` rather than merely+ dropping the variable, and xCAD.NET's `Release()` does exactly that in that order+ (§3.2).++**"`swApp.ExitApp()` does nothing when UserControl is True" is folklore** — we+found no primary source stating it as a rule. What the primary source *does* say+is the sentence above, from which the folklore is a reasonable (and probably+correct) inference: a session flagged as the user's, holding a live client+reference, has two independent reasons not to disappear. The practical upshot is+the same either way, and it is rung (a):++```+remember previous UserControl+UserControl = false // "this session no longer outlives its client"+ExitApp()+release EVERY reference this process holds, to zero+then measure+```++and, if the quit is declined, **restore `UserControl` to what it was**. Leaving a+user-launched session flagged `UserControl = false` is a booby trap: it invites+SOLIDWORKS to disappear the next time the com host exits or is respawned, which+this bridge does routinely (crash recovery, `process_kill` of a wedged host). That+restore is not optional politeness; it is the difference between a declined quit+and a delayed one.++### 1.3 `UserControlBackground`, `Visible`, `FrameState`, `CommandInProgress`++* **`UserControlBackground`** — whether the user can interact while a background+ automation task runs. The bridge sets it `true` **only on the COM-launch path**+ (`com.rs:178`), which on this edition never runs; an attached session never has+ it touched. It has no documented bearing on exit. Leave it alone.+* **`Visible`** — hiding a window does not close it, and hiding SOLIDWORKS to+ "make the quit quieter" would produce exactly the invisible-lingering-process+ that the batch-tooling threads complain about. **Never set `Visible = false` as+ part of a quit.**+* **`FrameState`** (`swWindowState_e`) — minimise/maximise/restore of the frame.+ Irrelevant to quitting, and it *is* journaled (the journal in §0.3 is full of+ `myModelView.FrameState = swWindowState_e.swWindowMaximized`), so touching it+ writes noise into the very artefact we use as evidence.+* **`CommandInProgress`** — the honest "is the app mid-command" read, and it is+ precondition P4. It is currently **unreadable** through `swx_com_eval` (§5.6).++### 1.4 `CloseAllDocuments`, and whether to close first++The API on this build exposes `ISldWorks::CloseAllDocuments(includeUnsaved) → bool`+(what the bridge calls today). We found **no `CloseAllDocuments2`** in the 2026 API+and did not assume one; `IModelDoc2::Quit` does not exist either — the+per-document discard primitive is `ISldWorks::QuitDoc(name)` ("closes the specified+document without saving changes"), which `pyswx` wraps and which our+`swx_close_doc` approximates with `CloseDoc`.++**Should the ladder close documents first? No — and this is a deliberate design+decision, not an omission.** At `openDocuments == Some(0)` there is nothing to+close, and *below* that precondition the bridge has no business quitting at all.+Folding a `CloseAllDocuments(true)` into `swx_stop` would mean the quit verb+silently discards documents, which is precisely the coupling+`swx_close_all_documents` was separated out to avoid (it is confirm-gated, and it+leaves dirty documents open unless `closeUnsaved:true`). A caller that wants a+clean slate calls that verb, sees what it skipped and why, and *then* calls+`swx_stop`.++### 1.5 `RunCommand(swCommands_App_Exit)` — the UI route, from inside++`ISldWorks::RunCommand(commandID, tabName)` dispatches a real SOLIDWORKS UI+command; the enumeration carries **3000+** ids. The one we want is++```+swCommands_App_Exit = 2825 // File > Exit+swCommands_FileClose = 2789 // File > Close (NOT what we want)+```++verified by fetching `tdsmith/swharness/swcommands.py` (a machine-extracted dump of+`swCommands_e`) and corroborated by search against the official `swCommands_e`+enumeration page. Do not confuse it with `swCommands_FileClose = 2789`, or with+`swCommands_Exit_Sketch = 862`.++Three caveats for the experiment. `RunCommand` is documented as not supported for+every command. **Whether it returns before or after the command completes could not+be established** — the official Remarks page is unreadable to every fetcher we have,+and CodeStack has a whole article on running commands *synchronously* precisely+because the default is not obviously so; the rung's measurement is therefore the+process census after a budget, never the call's return. And a `RunCommand` that+reaches the exit path will tear down the COM server underneath us exactly as+`ExitApp` does, so the rung must tolerate the same RPC-disconnect HRESULTs that+`com.rs` already whitelists.++(The purest form of this gesture is `WM_COMMAND` with `ID_APP_EXIT` posted to the+frame — on MFC that is literally what the menu item does — but the id has to be+harvested with Spy++ on this install, and CodeStack's own warning about the+technique is that *"sending a message in an unexpected way … might result in+unpredictable behavior (including crash)"*. `RunCommand` reaches the same+dispatcher with a documented signature, so that is the rung.)++### 1.6 `SendMsgToUser2` — never, in any rung++`SendMsgToUser2` puts a **modal dialog on the user's screen and blocks until+someone answers it**. It is already a named hazard in the eval allowlist+(`expr.rs::NAMED_HAZARDS`) for exactly that reason. It has no place in a quit+path: a bridge that pops a modal in order to close an application has created the+wedge it was trying to avoid. Likewise `SetUserPreferenceToggle` — there are+toggles that suppress prompts, and changing a user's SOLIDWORKS preferences+permanently in order to make one quit quieter is not a trade this bridge makes.+The rule stands: **the bridge quits only when there is nothing to prompt about.**++### 1.7 Connected-specific API facts++`ISldWorks::ApplicationType` (`swApplicationType_e`, `swApplicationType_3DEXPERIENCE`)+is the official branch point for Connected, per the programming-guide page+*"SOLIDWORKS Design Connected to the 3DEXPERIENCE Platform"*, which states that+*"some SOLIDWORKS APIs have been added, changed, or discontinued for use with+SOLIDWORKS Design connected to the 3DEXPERIENCE platform"* and that *"most working+SOLIDWORKS Design macros will also work"*. The documented deltas are all+file-storage-shaped (`GetSaveTo3DExperienceOptions`, `SaveTo3DExperience`,+`GetFilePLMID`, restricted open modes). See §4.4.++Measured today on this build, read-only:++| Probe | Result |+|---|---|+| `$sw.GetProcessID()` | `107688` — works |+| `$sw.UserControl` | refused by our own allowlist (`eval_member_blocked`) |+| `$sw.ApplicationType` | refused by our own allowlist (`eval_member_blocked`) |+| `$sw.IsPLMConnected()` | **`0x80020006` Unknown name — this member does not exist on this build**, although `expr.rs`'s own test cites it as an allowed read. Worth fixing that example. |++`IsSWApplicationClosing` — sometimes cited as the way to know a shutdown is in+progress — is an **add-in/event-side** notion (the `DestroyNotify` event and the+`SldWorks` event sink), not something an out-of-process client can poll, and this+bridge ships no add-in. It is not available to us and it is not in the design.++---++## 2. Window-manager routes, and exactly what each one means++### 2.1 First: get the HWND from COM, never by class name++`ISldWorks::Frame` → `IFrame::GetHWnd` is the documented way to obtain the frame+handle so Win32 can be used on it. **In 64-bit code the call is+`IFrame::GetHWndx64`** — the help says outright *"If your application must be x64+compatible, then you should use IFrame::GetHWndx64."* Our com host calls+`Frame().GetHWnd()` (`com.rs:2312`), reads it as an `int`, and has been lucky:+`6622700` fits in 32 bits. **That is a latent truncation bug on this bridge's only+identification of the application window, and it should be `GetHWndx64` with an+i64 read.**++And the negative result, which is worth as much: **the frame's window class name is+not a lookup key.** `"SW_Main"` is folklore with no primary source; SOLIDWORKS is+an MFC app whose classes are synthesized by `AfxRegisterWndClass` from+style/cursor/brush/icon (hence the `Afx:0000000140000000:8:…` we measured) and the+toolset number moves with the release (CodeStack drives its PropertyManager children+by class `AfxWnd140u` — MFC 14.0). Treat any class name as **diagnostic output**,+never as a matcher. `abwin::is_frame_window` is on the right side of this already:+it treats anything that is *not* `#32770` as a frame rather than matching `Afx:`+positively.++### 2.2 The gestures, and what each one really is++| Gesture | What it really is | Semantics against an MFC app like SOLIDWORKS | Verdict |+|---|---|---|---|+| **`WM_CLOSE` to the main frame** | the message a title-bar **X** produces | **This is File ▸ Exit, byte for byte.** MFC TN022: `ID_APP_EXIT` → `CWinApp::OnAppExit` → `m_pMainWnd->SendMessage(WM_CLOSE)`, and *"the standard shutting down of the application (prompting for dirty files and so on) is handled by the `CFrameWnd` implementation."* `CFrameWnd::OnClose` → `CWinApp::SaveAllModified` → per-document `SaveModified` prompts → `DestroyWindow` → the app exits. On this app the journal records that path as `swApp.ExitApp` (§0.3). | **the primary window route** |+| **`WM_SYSCOMMAND` / `SC_CLOSE` (0xF060)** | the system-menu *Close* item; also what a real Alt+F4 generates | `DefWindowProc` turns it into a `WM_CLOSE`, so it lands in the same handler — but through `OnSysCommand` first, and *"applications that modify the window menu must process WM_SYSCOMMAND messages"* (SOLIDWORKS customizes heavily), so an app that filters one may still honour the other. Field report: `PostMessage(WM_SYSCOMMAND, SC_CLOSE)` *"works in the scenarios in which CloseMainWindow() fails"* — minimized windows, different credentials. Its enabled state is also **readable** before you send it; on this frame it is enabled (§2.5). Mask `wParam` with `0xFFF0`. | **secondary route, and the only one whose availability we can check first** |+| **Restart Manager `RmShutdown` without `RmForceShutdown`** | the OS's own documented polite-shutdown sequence, targeted at a **pid** | `RmStartSession` → `RmRegisterResources(RM_UNIQUE_PROCESS{pid, creation time})` → `RmGetList` → `RmShutdown(0, cb)` sends `WM_QUERYENDSESSION(ENDSESSION_CLOSEAPP)`, then `WM_ENDSESSION`, *"and also sends a WM_CLOSE message for GUI applications that do not shut down on receiving WM_ENDSESSION."* Any app returning FALSE **cancels** the shutdown, and with `RmForceShutdown` omitted the Restart Manager **never terminates anything**; a refusal comes back as `ERROR_FAIL_SHUTDOWN (351)` with per-process `AppStatus`. It also takes the pid **and its creation time**, which is the same identity pair our attribution law already requires. | **a real rung — but LAST, see the hazard below** |+| **`WM_COMMAND(ID_APP_EXIT)` to the frame** | what the File ▸ Exit menu item literally posts | On MFC this is the most faithful possible File ▸ Exit simulation, and it is the community's documented "plan B" for a missing API command (harvest the id with Spy++, then `SendMessage swFrame.GetHWnd(), WM_COMMAND, id, 0`). Its author's own warning: *"sending a message in an unexpected way … might result in unpredictable behavior (including crash)."* The id must be harvested from **this** install. | **not in the ladder** — `RunCommand(2825)` reaches the same dispatcher through a supported API |+| **Alt+F4 as synthesized input** | `SendInput`, or posted `WM_SYSKEYDOWN` | `SendInput` needs the target **focused and foreground**, `SetForegroundWindow` *"cannot force a window to the foreground while the user is working with another window"*, it does not reset currently-held keys (a user holding Shift corrupts it), and — decisively — *"this function fails when it is blocked by UIPI. Note that **neither GetLastError nor the return value will indicate** the failure was caused by UIPI blocking."* Posted `WM_SYSKEYDOWN` is worse: accelerator translation happens in the message pump, so a posted key generally never becomes `SC_CLOSE` (Raymond Chen, twice). | **rejected**: steals focus, races real input, fails silently |+| **`WM_QUIT` / `PostThreadMessage`** | tells a message loop to stop pumping | *"Do not post the WM_QUIT message using the PostMessage function"* (docs, verbatim); `PostQuitMessage` is intra-process only. It would drop SOLIDWORKS' loop with **zero** cleanup — no `OnClose`, no `CanCloseFrame`, no `SaveAllModified`, no `ExitInstance` — and it is unreliable anyway: a thread in a modal loop never dispatches it, so *"the messages will be lost."* A soft kill wearing a message's clothes. | **rejected outright** |+| **`DestroyWindow`** | destroy the window directly | *"A thread cannot use DestroyWindow to destroy a window created by a different thread."* Impossible cross-process. | **impossible** |+| **`EndTask(hwnd, FALSE, FALSE)`** | one call: *"only the close with WM_CLOSE is attempted"* | Equivalent to rung (c) with less control, and documented as *"not intended for general use. It may be altered or unavailable in subsequent versions."* `fForce=TRUE` forcibly destroys the window — never. | **not used** |+| **`ShutdownBlockReasonCreate`** | how an app **blocks** shutdown | Callable only from the thread that created the window. It is a reason a shutdown might be *declined*, not a way to cause one. | **not applicable** |++**The Restart Manager hazard, and why it is last rather than first.**+`ENDSESSION_CLOSEAPP` means *"the application must shut down. **Any data should be+saved automatically without prompting the user.**"* At `openDocuments == Some(0)`+that is moot — there is nothing to save. But if P1 were ever wrong, the RM path is+the one rung that could cause SOLIDWORKS to **write the user's files without+asking**, where `WM_CLOSE` would raise a prompt that the ladder detects and stops+on. Defence in depth means preferring the rung whose failure mode is a *visible+dialog* over the rung whose failure mode is a *silent save*. So RM goes last, and+its precondition sweep is re-run immediately before it.++### 2.3 `SendMessage` vs `PostMessage` vs `SendMessageTimeout` — and the silent-failure trap++* **`SendMessage`** *"does not return until the window procedure has processed the+ message"*, and cross-thread *"the sending thread is blocked until the receiving+ thread processes the message."* If closing raises a modal, the window procedure+ does not return until that modal is answered — so a plain `SendMessage(WM_CLOSE)`+ can block the com host for as long as the dialog stands.+* **`SendMessageTimeout(..., SMTO_ABORTIFHUNG | SMTO_NORMAL, 5000)`** is the right+ instrument: same synchronous semantics, bounded, and it gives up early if the+ target *"has not called GetMessage or a similar function within five seconds."*+ On timeout it returns 0 and *"does not always call SetLastError"* — so+ `SetLastError(ERROR_SUCCESS)` first, then read it.+* **`PostMessage`** returns immediately and never blocks — correct for the+ `SC_CLOSE` rung, where the measurement is the process census anyway. It is also+ exactly what `Process.CloseMainWindow()` does internally.+* **THE TRAP — UIPI failures look like success.** Message sending is subject to+ UIPI: *"the thread of a process can send messages only to message queues of+ threads in processes of lesser or equal integrity level"*, and *"for compatibility+ reasons, SendMessage and other APIs will return success even if the API was+ blocked."* So a blocked `WM_CLOSE` returns `TRUE` and does nothing. Every message+ rung must `SetLastError(0)` first and check for `ERROR_ACCESS_DENIED (5)`, and+ report a UIPI block as its own outcome — **never as "declined"**. On this machine+ both processes run as Drew and `sldworks.exe` is not elevated (launched by the+ bridge's own shortcut chain), so this should not bite — but resellers routinely+ advise running SOLIDWORKS as administrator, and if Drew ever does, every message+ rung silently stops working. **Corollary: never elevate SOLIDWORKS, and never+ elevate the bridge.**++### 2.4 `Process.CloseMainWindow()` — the gesture the classifier denied++Worth spelling out, because it is the thing that was actually tried. Its .NET+implementation (`Process.Windows.cs`) is: read `GWL_STYLE`; if `WS_DISABLED` →+`return false`; else `PostMessageW(hwnd, WM_CLOSE, 0, 0)`. That is rung (c) with+less control and one extra behaviour: **with a modal up, the frame carries+`WS_DISABLED`, so it returns `false` having sent nothing at all.** Its documented+limits are explicit — *"does not force the application to quit"*, the app *"can+refuse to quit"*, and it is *"identical to a user closing an application's main+window using the system menu"*. `MainWindowHandle` is also a heuristic (*"the window+opened by the process that currently has the focus"*, cached, **zero for hidden+processes**), which is one more reason to take the hwnd from `IFrame::GetHWndx64`+instead. Same gesture, worse instrument, no measurement — see §6 for why its denial+from the agent's shell was correct.++### 2.5 What is true of THIS app, measured++| Question | Answer | Instrument |+|---|---|---|+| Is the main frame an MFC frame? | yes — class `Afx:0000000140000000:8:0000000000010003:…` (diagnostic only, see §2.1) | `GetClassName` |+| Is it visible and enabled? | yes and yes — **no `WS_DISABLED`**, so no modal owns it | `IsWindowVisible` / `IsWindowEnabled` |+| Is it hung? | no | `IsHungAppWindow` |+| Is `SC_CLOSE` enabled on its system menu? | **yes** — `GetMenuState(GetSystemMenu(hwnd,false), 0xF060, MF_BYCOMMAND) = 0x20001000`, `MF_GRAYED (0x1)` and `MF_DISABLED (0x2)` both clear | `GetMenuState` |+| Is a modal up? | no — the process owns **no** top-level `#32770` at all | `EnumWindows` + `GetWindowThreadProcessId` |+| Does `GetLastActivePopup` point elsewhere? | no — the frame's last active popup is itself (`6622700`): the "no modal owns me" signature | `GetLastActivePopup` |+| Was SOLIDWORKS launched with `/SWDisableExitApp`? | **NO** — the launcher log reads `ShellExecute …\sldworks.exe` with **no arguments**, and `Win32_Process.CommandLine` for pid 107688 is the bare exe path | launcher log + `Win32_Process` |+| Other top-levels in the process | the hidden VBA host `wndclass_desked_gsk` + 4 `VBFloatingPalette`, `NVOpenGLPbuffer` ×2, `PbrsHost`, `ListBox "Properties"`, `ThunderMain`, `MsoStdCompMgr`, `Chrome_WidgetWin_0`, tooltips — **all invisible**, none a dialog | window census |++That last-but-one row matters more than it looks: there is an **undocumented+`/SWDisableExitApp` switch** in circulation (described as suppressing the crash+window while debugging, and as used by SOLIDWORKS Rx). If the launcher were passing+it, `ExitApp` being ignored would have a trivial explanation. **It is not being+passed on this machine**, so that explanation is ruled out by measurement.++Everything else in that table is the state the ladder requires (§5.2), and it is+the state the app is in at rest.++### 2.6 What a Connected exit shows on screen — the honest state of knowledge++**With modified documents**, the dialog has a formal identity and it is documented+as the **application-exit** dialog, in the **SOLIDWORKS Connected** help set: the+*"Save Modified Documents"* dialog box (help topic id `HIDD_NEW_CLOSEALL_DLG`) —+*"…or closing the application with one or more modified documents open, you can save+modifications to multiple open documents in the Save Modified Documents dialog+box."* It appears only for **two or more** modified documents; a single dirty+document gets the ordinary per-document prompt instead — so a quit with one dirty+document and a quit with three raise *different* windows, and the classifier has to+catch both. On Connected the+save model is two-target (**Save to This PC** / **Save to 3DEXPERIENCE**, the latter+opening *Save as New* into a collaborative space), which is where the observed+**Save all to This PC / Save all to 3DEXPERIENCE / Don't Save / Cancel** buttons come+from. Corroborating the hazard: the Save-Modified dialog is repeatedly reported as+the thing that wedges API-driven work (*"SOLIDWORKS does not seem to wait for the+dialog box to disappear before running the rest of the code"*), and even `QuitDoc`+— documented to close **without** saving — still prompts for some users.++**The exact four-button wording is publicly undocumented.** *"Save all to This+PC"*, *"Save all to 3DEXPERIENCE"* and *"Don't Save"* in an exit context return+**zero hits** across the Connected help set, 3DSwym and the reseller blogs (the only+documented "Batch Save to 3DEXPERIENCE" is a Tools-menu bulk utility). Our+first-hand sighting is the only record of it; it must be characterised on the+machine, not looked up. Two related facts that constrain what that dialog can even+do: `Save to This PC` is what **Ctrl+S** does on Connected, and the platform `Save`+is **greyed out unless the file is Locked** — so "Save all to 3DEXPERIENCE" can be+an impossible choice at exit. All the more reason the bridge never picks one.++**With zero documents, three things are now settled from vendor sources:**++0. **With zero open documents, NOTHING is documented to appear.** Four independent+ search angles found no exit prompt of any kind for an empty Connected session.+ Design accordingly: do not build dialog automation around a phantom, and treat+ any window that does appear as an unrecognised one to report (§5.5).+1. **There is no sign-out prompt on exit, and exiting does not sign you out.**+ Sign-out is an explicit manual action (3DEXPERIENCE tab → Profile → Sign Out),+ and *"closing SOLIDWORKS or restarting your computer will not log you out"* — the+ token lives 24 h by default, 7 days with "Remember Me", and there is **no licence+ check-in at exit**. So no rung needs to answer a sign-out question, and the+ bridge must never initiate one: the platform session is Drew's, not this app's.+2. **There IS a documented exit-adjacent telemetry modal**: the *"Status Feedback"*+ dialog, present in the **SOLIDWORKS Connected** help set — *"when you close the+ SOLIDWORKS software and the dialog box appears, you can send your comments and+ performance log data to DS SolidWorks Corp"*. Its documented context is an+ unresponsive command, so it is hang-triggered rather than routine, but it is a+ real window a quit can meet. It is classified `unknown` → `neverAutoAnswer` by+ `abwin::classify` today, which is the correct default: **report it, never send+ telemetry on the user's behalf.** (The routine Customer Experience Improvement+ Program is a *System Options checkbox*, not an exit prompt — so Status Feedback+ is the only exit-adjacent feedback modal there is.)+3. **No documented "content not saved to the platform" exit warning, no MySession+ unsaved-content prompt, no licence check-in prompt, no "SOLIDWORKS is closing"+ progress dialog.** The MySession SolidPractices covers refresh, the PLMCSA+ client/server split and open/save — and says nothing about session teardown.++**And the processes that are SUPPOSED to survive.** This is the correction that+matters most for §5.4's measurements, because getting it wrong would make a perfect+quit look like a failure:++| Process | What it is | Survives a graceful exit? |+|---|---|---|+| `ENOPLMCSAClient.exe` | DS SolidPractices: *"a helper process that connects SOLIDWORKS to the 3DEXPERIENCE Platform … to save, open and manage SOLIDWORKS data."* Has its own tray icon. | **YES, by design.** Every VAR reset procedure reads *"close SOLIDWORKS, **then** right-click and exit PLM services in the tray."* |+| `EdmServerV6.exe` | "3DEXPERIENCE PLM Services" — the XML messaging server behind the PLMCSA client; separate tray app | **YES, by design** (same guidance) |+| `3DEXPERIENCELauncher.exe`, `…Backbone.exe`, `…GUI.exe`, `…SysTray.exe` | the Platform's launcher service ("a listening port to launch Native apps") + tray | **YES, by design.** Ending one is the documented *pre-reinstall* step, never a shutdown step — and they are already on this bridge's `NEVER_TERMINATE` prefixes |+| `sldBgDwld.exe` | SOLIDWORKS Background Downloader | **YES, by design** |+| `ViewServerV6.exe`, `CATSTART.exe`, `CATSysDemon.exe`, `AsyncSaveinto3DEXPERIENCE.exe` | Connected-side servers/bootstrap | **undocumented — measure, do not assert** |+| `sldProcMon.exe`, `sldsurfacing.exe`, `sldimport.exe`, `swCefSubProc.exe` | SOLIDWORKS' own per-session out-of-process servers and CEF children | **expected to go.** These are the reaping signal |++So "SOLIDWORKS quit" can never be asserted from "no Dassault process remains" — it+is asserted from the `SLDWORKS.exe` pid **and its creation time** (§5.4), with the+per-session children as the *quality* signal and the tray/daemon set explicitly+excluded from both.++One more asymmetry worth recording: **`SWXDesktopHelper.exe` and+`SWXDesktopLauncher.exe` have no published documentation of function anywhere** — not+DS, not file.net, not the VAR blogs; the only public facts are that their+`%APPDATA%\DassaultSystemes\<name>` folders are the diagnostic-log directories+support asks for. Which means §0.3–0.4's measurements (a 30-second licence heartbeat,+a `Disconnect` line on shutdown, and nothing at all in a shortcut-launched session)+are **original evidence** about what that helper does. Worth keeping.++### 2.7 What a killed or half-closed SOLIDWORKS actually costs++This is the evidence that a kill is not merely impolite, and it lines up with the+launcher trouble in `docs/LAUNCH-RESILIENCE.md`:++* **The journal file stays locked.** GoEngineer's top cause of *"A journal file+ could not be created. Auto recover will not work. Another session of SOLIDWORKS+ may already be running on this machine."* is *"a previous instance … When a+ session doesn't close properly, its journal file stays locked."* Our own journal+ rotated cleanly at 15:36 (`swxJRNL.swj` → `.BAK`), which is one more sign the+ 15:24 exit was clean.+* **Auto-recovery is silently skipped**: *"in the case that the active session was+ killed by the user, the recovery window will not appear"* — the `.SWAR` files must+ be found by hand.+* **A network licence can stay checked out** until the SNL service is restarted or+ a FlexLM `TIMEOUT` expires. (Connected uses platform licensing rather than SNL,+ so this is background rather than our case.)+* **An orphaned `SLDWORKS.exe` suppresses auto-save in the next session** and+ produces the "another session running" warning; the folk fix is renaming+ `HKCU\Software\SolidWorks\SOLIDWORKS 20xx\ExtReferences`.+* **On Connected, platform objects you had Locked stay locked to you** after a kill,+ and unlocking is a manual gesture in the task pane / Bookmark Editor (the files do+ not even have to be open). That is a cost a kill imposes on the user's *cloud*+ data, not just their session — and it is the sharpest single argument for the+ ladder over `swx_kill` on this edition.+* On the Connected side, Dassault's own Makers-support wiki lists the processes to+ end when the launcher insists SOLIDWORKS is already running — **SWXDesktopHelper,+ SWXDesktopLauncher, CATSTART** — plus the caches to clear+ (`%TEMP%`, `%LOCALAPPDATA%\Temp\swcefcache`,+ `%LOCALAPPDATA%\DassaultSystemes\CATTemp`, and `%APPDATA%\DassaultSystemes\CATSettings`+ for login failures). Note that this list is Dassault telling a *human* how to+ recover, and that three of those four are already on this bridge's+ `TERMINABLE_IMAGES` / `NEVER_TERMINATE` calculus for good reasons.++### 2.8 The in-house precedent, and the in-house hazard++**Precedent.** The Fusion 360 bridge — the gold standard this bridge mirrors —+quits its host application exactly this way. `fusion_stop`'s catalog entry reads+*"Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait"*, with a+`skipCleanClose` flag (`docs/_fusion-inventory.md` line 29). A window-manager close+of the main frame is not exotic in this codebase; swx is the outlier for having+only `ExitApp`.++**Hazard, first-hand, 2026-09-02.** Closing a **stuck SOLIDWORKS modal** through+the window manager *took the whole application down with it* — recorded three times+in the source as the reason `swx_dismiss_blocking_dialogs` reports by default+instead of closing (`src/verbs3.rs:740`, `src/abwin.rs:49`). That is evidence about+**dialogs**, not about the frame, and it is exactly why the ladder demands an empty+dialog census before it touches the frame, and why the frame route must never be+used *as* a dialog route.++**One route this design deliberately does not use.** ab's close is+`desktop_ui_window {action:"close"}` — a UIA `WindowPattern.Close()`. It is the very+mechanism involved in the 2026-09-02 incident; it is unavailable whenever ab cannot+be reached (`swx_status.abAvailable:false` on the dev bridge today, while the+ab-spawned installed instance is still 0.3.3); and it offers no control over+sync-vs-post, no timeout, and no `GetLastError` to check for a UIPI block — which is+the entire point of using `SendMessageTimeout`. The com host already has the hwnd,+an STA thread and the `windows` crate. The message belongs there (§5.6).++---++## 3. What every other automation project does (spoiler: nothing)++Surveyed by reading the code, not the READMEs: the five SOLIDWORKS MCP servers in+`docs/ECOSYSTEM-TABLE.md`, the two serious .NET frameworks, the Python wrappers,+and the batch/PDM field practice.++### 3.1 The MCP servers — five for five never quit SOLIDWORKS++| Project | What it does at shutdown | Connected? |+|---|---|---|+| **SolidPilot** (`eyfel/mcp-server-solidworks`, AGPL-3.0) | Nothing. `SolidWorksService.cs` attaches via `Marshal.GetActiveObject`, sets **`UserControl = true`**, and has no `ExitApp`, no `ReleaseComObject`, no `IDisposable`. Its STA pump (`StaExecutor.cs`) is `IsBackground = true` with no shutdown path and no `CoUninitialize`. README documents `ensure_ready` ("launches SolidWorks via COM") with **no matching teardown tool**. | not addressed |+| **vespo92/SolidworksMCP-TS** (MIT) | Explicit refusal. `winax-adapter.ts`: `// Don't close SolidWorks, just disconnect` — nulls the references and returns. | not addressed |+| **andrewbartels1/SolidworksMCP-python** (MIT) | Best COM hygiene in the field and still never exits: `disconnect()` nulls every cached object then `pythoncom.CoUninitialize()`. | not addressed |+| **alisamsam/Solidworks-MCP** (MIT) | Nothing at all — no `CoUninitialize`, no `__del__`, no close. The session ends when Python does. | not addressed |+| **Adam-Schildkraut/solidworks-mcp** (MIT) | No application-lifecycle code exists; it is a VBA-template generator. | not addressed |++**Not one of the five calls `ExitApp`. Not one kills the process either.** They+are all safe only because they attach to a session a human started and walk away+from it. On the actual problem — *quit this application, gracefully, on a+Connected edition* — the public field has no answer, and no competitor's row in+our ecosystem table can be read as one.++### 3.2 The frameworks: one correct implementation, and the rule for in-process code++**xCAD.NET (`xarial/xcad`) is the reference.** `SwApplication.cs`:++```csharp+public void Close() { if (!m_IsClosed) { m_IsClosed = true; Sw.ExitApp(); Dispose(); } }++internal void Release(bool close) {+ …+ m_Documents.Dispose(); TagsRegistry.Dispose();+ if (close && !m_IsClosed) Close();+ if (Sw != null && Marshal.IsComObject(Sw)) Marshal.ReleaseComObject(Sw);+}+```++The ordering is the lesson: **dispose your own wrappers → `ExitApp()` → release+the root COM pointer LAST.** It also exposes+`Process => Process.GetProcessById(Sw.GetProcessID())` — it knows the pid and+**still never calls `Kill()`**. (It additionally knows the command-line switch+`/SWSafeMode /SWDisabExitApp` — a documented flag that *disables* `ExitApp`, worth+remembering if a launcher ever injects it.)++**SolidDNA / CADBooster states the opposite rule for in-process code**, and it is+also right: `SolidWorksApplication.Dispose()` carries+`// NOTE: Don't dispose the application, SolidWorks does that itself`. An add-in+releases; it never exits — and per 3DSwym it cannot even veto a shutdown+(returning `False` from `DisconnectFromSW()` does not stop it). The add-in+convention (`SwCSharpAddinMF`, `solidworks_urdf_exporter`) is+`Marshal.ReleaseComObject` on everything plus `GC.Collect()` ++`GC.WaitForPendingFinalizers()` **twice**.++**Python.** `deloarts/pyswx` wraps the whole relevant surface — `exit_app()`,+`close_all_documents(include_unsaved)`, `close_doc(name)`, a `user_control`+setter, and **`quit_doc(name)`** ("closes the specified document without saving+changes"), which is the discard-and-close primitive our `swx_close_doc` currently+emulates with `CloseDoc`. The universal pywin32 recipe is+`Dispatch("SldWorks.Application")` → work → `sw.ExitApp()`, with the community+folk-rule that `Dispatch` **starts** SOLIDWORKS (and therefore owns its lifetime)+while `GetActiveObject` **attaches** (and therefore does not) — which is exactly+the `UserControl` distinction in §1, arrived at empirically.++### 3.3 Macros can exit their own host, and there is a command id for File ▸ Exit++Two routes nobody in the ecosystem uses, both directly usable by us:++* **A `.swp` VBA macro can call `swApp.ExitApp` and it works** — the API help+ merely *discourages* it ("not normally used with macros … because it shuts down+ your SOLIDWORKS session"), and forum practice confirms it stops SOLIDWORKS.+ This is the in-process rung (e) in §5: the request comes from inside the process,+ where no external client's reference count is in play. It matters especially+ here because this edition **restricts add-ins but runs macros**, and the VBA+ host is already loaded in the live session (§0.1).+* **`swCommands_e` contains `swCommands_App_Exit = 2825`** (cross-checked in+ `tdsmith/swharness/swcommands.py`, alongside `swCommands_FileClose = 2789`),+ usable via `ISldWorks::RunCommand`. That is "press File ▸ Exit for me", inside+ the app's own command dispatcher. Nobody surveyed uses it for exit. It is rung+ (b).++### 3.4 Batch and PDM practice: killing is normal, and even there the mature form is targeted++This is where the field does end sessions, and it is not a model to copy:++* SOLIDWORKS forum thread/34178 ("How Close or Kill Process SLDWORKS.exe c#") —+ after a program finishes `sldworks.exe` keeps running, so developers reach for+ `CloseMainWindow()` then `Kill()`; `taskkill /f /im` in a batch file is described+ as ordinary practice.+* myCAD forum, *"Kill SLDWorks.exe -Embedding"* — the fullest discussion found.+ Consensus: **`ExitApp()` alone does not prevent the lingering process**; killing+ every SOLIDWORKS process is "a bit brutal" when another session exists; and the+ accepted refinement is to **set a distinctive `MainWindowTitle` on your own+ instance first and kill only the process whose title matches**. If swx ever+ needs a last-resort escalation, *that* is the shape it should take — a+ self-tagged instance, never an image-name match. It is not in this design.+* CATI, "Killing the SOLIDWORKS Task Scheduler!" — `taskkill /f /im sldProcMon.exe`+ as the remedy for a scheduler that will not stop. Note `sldProcMon.exe` is one+ of the per-session children in §0.2 whose reaping we use as a clean-exit signal.+* **Dassault publishes no endorsement of termination.** The Task Scheduler help+ documents task status transitions and says nothing about how a session is ended.++So our never-kill posture is not merely defensible, it is the differentiated+position — and the honest way to state it is that the field's alternative is+`taskkill`, which is what the bridge exists to avoid.++---++## 4. The 3DEXPERIENCE side: does a clean quit need a sign-out?++**Short answer, from this machine: no — and there is nothing of the launch chain+left alive to sign out of.**++### 4.1 The launcher is gone long before the quit++`CATSTART.exe` → `SWXDesktopLauncher.exe` is a *starter*, not a supervisor. Today's+launcher log ends:++```+15:35:41 info Login Success+15:35:41 info Writing PlatformInfo to HKCU\Software\Dassault Systemes\SolidWorks Shared\Servers\3DEXPERIENCE+15:35:41 info Started from desktop shortcut skipping PLMCSA client start+15:35:41 info Launching Application: …\SOLIDWORKS\sldworks.exe+15:35:41 info ShellExecute …\sldworks.exe+15:35:41 info Launched successfully ExitCode: 0+15:35:41 info Run End+```++Both chain processes then exit — neither is in the census (§0.2). The+`3DEXPERIENCELauncher*` daemons that *are* alive belong to the Platform, not to+this session, are **not required for a launch** (the 2026-09-04 success ran with+no Backbone at all), and are already on the bridge's `NEVER_TERMINATE` prefix list.+Nothing about a quit involves them.++### 4.2 The licence heartbeat: present in a Platform launch, ABSENT in ours++This is the one asymmetry that matters, and it was measured, not assumed.++| | Previous session (17:23 on 09-04 → 15:24 on 09-08) | Current session (bridge-launched 15:35 on 09-08) |+|---|---|---|+| `SWXDesktopHelper` process | ran for four days | **none** |+| its log | 588 KB — token refresh (`iam.3dexperience.3ds.com/api/authenticated/user/me`), `LicenseList=NSR,XWB`, `Heartbeat failures: none`, then `Offline License Count 0` every 30 s | **0 bytes** (both `….log` and `…_dslcc.log`) |+| at shutdown | `15:24:04 info Disconnect`, then the process ended | n/a |+| platform record | — | `3dexperience-log.json`: one `Login` operation (`loginEx` → `setsecuritycontext` → `getcastransientinfo`, 6.1 s), tenant `OI000616940`, pid 107688, `"integration":"SOLIDWORKSConnected"`, and nothing since |++So a **Platform** launch runs a helper that heartbeats a licence and logs+`Disconnect` when the session ends; a **shortcut** launch (which is what the bridge+does, and what the launcher log calls out: *"Started from desktop shortcut skipping+PLMCSA client start"*) does not. Two consequences for the design:++1. **Do not treat a missing helper `Disconnect` as a failed quit.** In a+ bridge-launched session there is no helper to write one. The platform-side signal+ available is the `3dexperience-log.json` tail — **not** the exit of+ `EdmServerV6.exe` / `ENOPLMCSAClient.exe`, which are the *3DEXPERIENCE PLM+ Services* tray apps and are documented to survive a graceful exit (§2.6).+ `ViewServerV6.exe` is undocumented: record it, conclude nothing.+2. **Do not sign anything out.** There is no sign-out step the bridge can take+ that is *its* to take: the token belongs to Drew's Platform session, and+ vendor guidance for the tray agent (3DEXPERIENCE PLM Services) is explicitly a+ **human** gesture — *"close SOLIDWORKS, then right-click and exit PLM services+ … in the Windows task tray"* (swyftsol KB). PLM Services legitimately outliving+ SOLIDWORKS is normal and is **not** a leaked process, which also means "did it+ quit" can never be asserted from "no Dassault processes remain". It is asserted+ from the `SLDWORKS.exe` pid, and only that.++### 4.3 Does a graceful quit cost the next launch anything?++Unknown, and it is question 6 of the gate (§5.7 step 5). What we have:++* **After the 15:24 shutdown** — the one with the journal's `ExitApp` + `End Sub`+ and the helper's `Disconnect` — the very next launch was the bridge's own, at+ 15:35, and it **worked**: launcher log 15:35:36, `sldworks.exe` at 15:35:41,+ `Run End`, ledger `solidworks_launched … route:"platformShortcut"`. One clean+ exit, one healthy relaunch, eleven minutes apart. That is a single data point and+ it is in the right direction.+* **After the killed instances** (`docs/LAUNCH-RESILIENCE.md`, FAILURE MODE A) the+ launcher stalled at `SecurityContext:` with no `Run Begin`, 5 of 5 times — but+ every one of those launches was also issued from a *detached, hidden* bridge+ context, which is the confounder the whole launch state machine exists for. The+ evidence cannot separate "the previous exit was dirty" from "this launch context+ is wrong", and this document must not pretend otherwise.+* Four dead sessions left `%TEMP%\swx<pid>` behind, the clean one included, so+ that is not a discriminator either (§0.3).++**The measurement that would settle it** is in the protocol: quit gracefully, then+immediately `swx_start` and record `Run Begin` latency, `sldworks.exe` latency and+COM-attach latency against the healthy 2026-09-08 baseline (+2 s / +5 s / 1–3 min).+If a graceful quit leaves the launcher healthy and a kill does not, that is the+strongest possible argument for the ladder — and the strongest possible argument+for never using `swx_kill`.++### 4.4 What Dassault documents about Connected and the API++* The API **works** on Connected; the deltas are file-storage-shaped. The+ programming-guide page *"SOLIDWORKS Design Connected to the 3DEXPERIENCE+ Platform"* says *"some SOLIDWORKS APIs have been added, changed, or discontinued+ for use with SOLIDWORKS Design connected to the 3DEXPERIENCE platform"* and+ *"Most working SOLIDWORKS Design macros will also work"*, and names+ `ISldWorks::ApplicationType` (`swApplicationType_3DEXPERIENCE`) as the official+ branch point, with `GetSaveTo3DExperienceOptions` / `SaveTo3DExperience` and+ `GetFilePLMID` on the storage side.+* **`ExitApp` is not on any published discontinued-for-Connected list**, and this+ pass found **no public report of `ExitApp` being declined on a Connected or+ Makers edition**. If ours reproduces at a verified 0 documents, it is a new+ result and worth publishing.+* Add-ins are restricted on this edition; **macros run** (forum thread/245766) —+ which is what makes rung (e) available.+* PDM Pro's API is explicitly out on Connected; the platform supplies its own+ lifecycle management.+* `CO_E_SERVER_EXEC_FAILURE` (`0x80080005`) on the SldWorks CLSID — our launch+ refusal — is **also** the classic signature of COM-activating SOLIDWORKS from a+ non-interactive context, not proof that Connected forbids activation. Worth+ knowing, and orthogonal to quitting.++---++## 5. The design: `swx_stop` v2, a measured ladder++### 5.1 What is wrong with v1++`swx_stop` today (`src/verbs.rs:1008`, host side `src/comhost/com.rs:1293`) is:++```+gate on unsavedDocuments == Some(0) (unless force:true)+ -> writes::authorize_in(EndSession) [reason + confirm + process opt-in]+ -> host "stop": ExitApp, tolerate the RPC-disconnect HRESULTs,+ clear the created-doc registry,+ VERIFY with GetActiveObject -> stopped:!still_up+ -> stopped:false => errorCode host_app_stop_declined+```++Five defects, in the order they bite:++1. **It gates on `unsavedDocuments`, not on the OPEN document count.** Right now+ this session has **5 clean documents of Drew's open** and `unsavedDocuments:0`+ — so a bare `swx_stop` (with the process opt-in on) would sail past the guard+ and try to quit a session full of the user's work. The rule Drew stated is+ the correct one and the code does not implement it: **quit only when the+ verifiable open-doc count is `Some(0)`.**+2. **The verification is done while we still hold a reference to the object.**+ `let sw = self.app(...)` is a live `IDispatch` for the whole match arm; `self.sw+ = None` drops the *cached* clone but not the local. `GetActiveObject` is then+ asked "are you still there?" while our own com host is one of the reasons the+ answer could be yes. Per the documented `UserControl` semantics (§1) an+ automation client's outstanding reference is exactly what keeps a SOLIDWORKS+ alive, so **v1 cannot distinguish "declined" from "quit, but pinned by our own+ pointer"**. Every field write-up of this bug ends in `Marshal.+ FinalReleaseComObject`; we have no equivalent.+3. **`GetActiveObject` is the wrong instrument anyway.** It answers a question+ about a ROT registration, not about a process or a window. The measurement+ that matters — did pid 107688 exit — is available without COM and is what+ `swx_status` already reads (`solidworks.processes[]`, `responding`).+4. **There is exactly one rung.** `ExitApp` or nothing. When it is declined the+ verb reports honestly (which is right) and then the only thing left in the+ bridge is `swx_kill` (which is wrong).+5. **Nothing records the attempt unless the host answered.** A refusal at the+ unsaved/opt-in gate writes no ledger line, so "we tried to quit and were told+ no" is invisible after the fact.++### 5.2 The preconditions — all of them, every rung, re-measured++A rung may only run when **all** of these are true, measured immediately before+it (not inherited from the caller, not cached from an earlier verb):++| # | Precondition | How it is measured | On unknown |+|---|---|---|---|+| P1 | `openDocuments == Some(0)` | `documents()` — the enumeration that only believes an empty list when `GetDocumentCount` agrees | **refuse** (`open_docs_unknown`) |+| P2 | `unsavedDocuments == Some(0)` | `GetSaveFlag` per document; vacuously true under P1 | **refuse** (`unsaved_unknown`) |+| P3 | no blocking dialog owned by this pid | window census, `#32770` top-levels owned by `processId` (see §5.6 — the com host must read this itself) | **refuse** (`dialog_present`) |+| P4 | not busy | `CommandInProgress == false` and the host's request mutex is free | **refuse** (`host_app_busy`) |+| P5 | responding | `IsHungAppWindow(mainHwnd) == false` | **refuse** (`host_app_hung`) |+| P6 | authorized | `writes::authorize_in(EndSession)`: process opt-in + `confirm` + a human `reason` | refuse (unchanged) |+| P7 | identity captured | `GetProcessID()`, the process creation time, and `Frame().GetHWnd()` read and stored **before** the first rung | **refuse** (`identity_unknown`) |++P7 is not bureaucracy: it is the same attribution law the launch path runs on+(*no handle, no kill* — `docs/LAUNCH-RESILIENCE.md` §8b). Without a pid **and**+its creation time, "the process went away" cannot be told from "a pid was reused",+and every rung's success measurement depends on that distinction.++`force:true` may waive **P2 only** (the user saying "the open work is+disposable"). It must **never** waive P1, P3, P4, P5 or P7 — those are not+questions about the user's data, they are the difference between a quit and a+wedge. This is a deliberate narrowing of today's `force`, which waives the whole+front gate.++### 5.3 The ladder++Each rung: **precondition sweep → act → wait → measure → decide**. A rung that+cannot be measured is a rung that FAILED, never one that worked. Nothing+escalates past a dialog: if any rung raises a top-level window, the ladder+**stops** and reports (§5.5).++| Rung | Action | Why it is here | Budget |+|---|---|---|---|+| **a** | `UserControl = false` (remembering the previous value) → `ExitApp()` → **release every reference we hold** (drop the local `Disp`, drop `self.sw`, leave no `IDispatch` parked in any cache) → restore `UserControl` if it declined | The documented sequence. `UserControl` is *the* flag that decides whether SOLIDWORKS outlives its automation client (§1.2), and the community's compensating step for "`ExitApp` did nothing" is always the final release (`Marshal.FinalReleaseComObject`). v1 does neither. | 30 s |+| **b** | `RunCommand(swCommands_App_Exit = 2825, "")` | Ask the UI to do what File ▸ Exit does, through a supported API, inside the app's own command dispatcher. Nobody in the ecosystem uses it. (The purest form is `WM_COMMAND(ID_APP_EXIT)` to the frame, but that id must be harvested with Spy++ per install and its own author warns it can crash the app — `RunCommand` reaches the same place with a documented signature.) | 30 s |+| **c** | `SendMessageTimeout(mainHwnd, WM_CLOSE, 0, 0, SMTO_ABORTIFHUNG\|SMTO_NORMAL, 5000)` from the com host, with `SetLastError(0)` first and a check for `ERROR_ACCESS_DENIED` | What clicking **X** does, and on MFC *literally* what File ▸ Exit does (`ID_APP_EXIT` → `OnAppExit` → `SendMessage(WM_CLOSE)`, TN022). The 15:24 evidence (§0.3) says this edition answers it by running its documented exit path — journal `swApp.ExitApp` + `End Sub`. Timeout-bounded, so a modal cannot wedge the com host. | 45 s |+| **d** | `PostMessage(mainHwnd, WM_SYSCOMMAND, SC_CLOSE, 0)`, same UIPI check | The system-menu Close, measured **enabled** on this frame (§2.5) — the one route whose availability we can read before sending. Posted, so it cannot block. It enters through `OnSysCommand` before `DefWindowProc` turns it into a `WM_CLOSE`, so an app that filters one may honour the other, and it is reported to work where `CloseMainWindow()` fails (minimized, cross-credential). | 45 s |+| **e** | `RunMacro2` of a two-line `.swp` shipped in `resources/macros/` (`Sub main() : Application.SldWorks.ExitApp : End Sub`) | The *in-process* exit: the request comes from inside the process being asked to quit, where no external client's reference count and no `UserControl` bookkeeping applies. This edition restricts add-ins but runs macros, the VBA host is already loaded (§0.1), and the API help only *discourages* `ExitApp` from a macro — it works. **Gated as `Kind::RunMacro` on top of everything else, and only ever this one shipped, content-hashed macro** — never a generated one. | 60 s |+| **f** | Restart Manager: `RmStartSession` → `RmRegisterResources(1 × RM_UNIQUE_PROCESS{pid, creationTime})` → `RmGetList` → **`RmShutdown(0, cb)` with `RmForceShutdown` OMITTED** | The OS's own documented polite shutdown, and the only route that takes the pid **and its creation time** — the same identity pair our attribution law demands. It sends `WM_QUERYENDSESSION(ENDSESSION_CLOSEAPP)`, then `WM_ENDSESSION`, *"and also sends a WM_CLOSE message for GUI applications that do not shut down on receiving WM_ENDSESSION"*, and with the force flag omitted **it never terminates anything**: a refusal returns `ERROR_FAIL_SHUTDOWN (351)` with per-process `AppStatus`. Must run in the user's interactive session (it does). | 90 s |+| — | **STOP.** Report `host_app_stop_declined` with every rung's measurement. | There is no rung (g). `swx_kill` is a separate, differently-gated verb, and `swx_stop` must never call it, escalate to it, or recommend it in a hint. | — |++**THE LADDER-WIDE INVARIANT: no rung runs while this process holds a COM reference+to SOLIDWORKS.** Rung (a) ends by releasing everything; rungs (b)–(f) must *start*+from that released state, and the com host must re-attach (`GetActiveObject`) only+to take a measurement, then release again before the next rung. The reason is the+failure mode the whole field reports: an automation client's outstanding reference+is what keeps a SOLIDWORKS alive, and the classic outcome is a **window that closes+and a process that does not** — the invisible `SLDWORKS.exe -Embedding` orphan. A+`WM_CLOSE` sent while we still hold an `IDispatch` is the most direct way to+manufacture exactly the half-closed state this design exists to avoid. Equally: any+rung that *succeeds* tears the COM server down underneath us, so (a), (b) and (e)+must all tolerate the RPC-disconnect HRESULTs `com.rs` already whitelists, and the+verification must never be a COM call.++**Why the Restart Manager is LAST and not first.** It is the most sanctioned route+on the list, and it is also the only one that can make SOLIDWORKS write to the+user's files without asking: `ENDSESSION_CLOSEAPP` means *"the application must shut+down. **Any data should be saved automatically without prompting the user.**"* At+`openDocuments == Some(0)` that is moot. But preconditions can be wrong, and when+they are, the rung whose failure mode is a **visible dialog we detect and stop on**+is safer than the rung whose failure mode is a **silent save**. Defence in depth+puts the message rungs ahead of it. (`EndTask(hwnd, FALSE, FALSE)` — documented as+*"only the close with WM_CLOSE is attempted"* — is rung (c) with less control and is+flagged *"not intended for general use"*; it is not in the ladder. `fForce=TRUE` and+`RmForceShutdown` are never used, by construction.)++Ordering rationale: (a) first because it is the documented API contract and the only+rung with no UI side effects at all; (b) next because a command id stays inside the+app's own dispatcher while a window message crosses the process boundary; (c) before+(d) because `WM_CLOSE` is the semantic close and `SC_CLOSE` is a mechanism that+produces one; (e) after the message rungs because it is the only rung that executes+code inside the user's SOLIDWORKS; (f) last for the silent-save reason above.++**Rungs are opt-in-able and individually disableable** — `swx_stop {maxRung:"a"}`+(default **"a"** until the gate has run) so the live experiment can advance one+rung at a time and a deployment can pin the ladder at whatever the gate proved.+Shipping the whole ladder armed on day one would be exactly the mistake crash+recovery avoided by shipping `off`.++### 5.4 The measurements — per rung, all from the machine++Recorded for every rung, whether it worked or not. Nothing in this table is read+from a COM return value.++| Signal | Instrument | Meaning |+|---|---|---|+| **process gone** | pid **and** creation time absent from a live census | THE success criterion. A pid alone is not enough (reuse). |+| exit latency | seconds from action to census-confirmed absence | pace the progress block; feeds `swx_verb_times` |+| **per-session children reaped** | `sldProcMon` / `sldsurfacing` / `sldimport` / `swCefSubProc*` gone | clean vs abrupt. A kill orphans them. |+| **the tray/daemon set UNTOUCHED** | `ENOPLMCSAClient`, `EdmServerV6`, `3DEXPERIENCELauncher{,Backbone,GUI,SysTray}`, `sldBgDwld` **still alive** — all of them survive a graceful exit **by design** (§2.6) | consent. Their absence would be a bug in us, not a cleaner quit. `ViewServerV6` / `CATSTART` / `CATSysDemon` are undocumented: **record, do not judge** |+| **journal closed** | `%APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\swxJRNL.swj` tail contains `swApp.ExitApp` followed by `End Sub` | **the app ran its own exit path** (§0.3). The single best clean-shutdown proof on disk. |+| platform released | `3dexperience-log.json` gains a logout/disconnect operation; a helper `Disconnect` line **if** a helper is running (§0.4 — it is not, in a bridge-launched session). **NOT** the exit of `EdmServerV6` / `ENOPLMCSAClient`: those are supposed to stay | the 3DEXPERIENCE side let go. Note there is **no licence check-in at exit** by design, so a released licence is not a signal to look for |+| **no new window** | window census before vs after: zero new top-level windows owned by the pid | a save prompt, a sign-out prompt or a survey appeared → STOP |+| no crash reporter | no **SOLIDWORKS Error Report** dialog (it has its own page in the *Connected* help set, so this edition has it too), nothing new under `%APPDATA%\SOLIDWORKS` | the exit was not a crash. (`swSendToService.exe` is **not** attested anywhere — do not look for it.) |+| **journal lock released** | the NEXT launch does **not** warn *"A journal file could not be created … Another session of SOLIDWORKS may already be running on this machine"*, and `swxJRNL.swj` rotates to `.BAK` | the documented top cause of that warning is *"a previous instance … when a session doesn't close properly, its journal file stays locked"* — so this is a direct, next-launch-visible test of cleanliness |+| **platform locks released** | nothing the session had **Locked** on the platform is still locked (checked by the user in the task pane, not by the bridge) | a kill leaves Connected objects locked to the user, unlockable only by hand (§2.7) |+| relaunch health | the NEXT `swx_start`: does the launcher reach `Run Begin` on the first route, and in how long | the yesterday-evidence question — does a graceful exit cost the next launch anything |+| ROT gone | `GetActiveObject` fails **after every reference is released** | kept only as a cross-check; never the criterion (§5.1 defect 3) |++### 5.5 Dialogs: classify, never answer++The census runs before every rung and again after each one. Any top-level+`#32770` owned by the SOLIDWORKS pid stops the ladder immediately. What is then+reported depends on the class, and **no class is ever answered by the bridge**:++| Class | Recognised by | What `swx_stop` does |+|---|---|---|+| Save prompt — the per-document prompt (1 dirty doc) **or** the *"Save Modified Documents"* dialog (≥2 dirty docs), which on Connected offers **Save all to This PC / Save all to 3DEXPERIENCE / Don't Save / Cancel** | `classify()` → `save_changes`, `neverAutoAnswer` | **REFUSE and REPORT.** `errorCode:"save_prompt_open"`, the button labels quoted verbatim, and the instruction that answering it is the user's decision. Never click, never Escape, never `WM_CLOSE` the dialog. Under P1 this dialog *should be impossible*; if it appears, our document count was wrong, which is a bug to report, not a dialog to dismiss. |+| Document Recovery | `document_recovery` | refuse + report; `swx_check_recovery` describes it |+| Licence / 3DEXPERIENCE sign-in or sign-out / session | `license` (matches `licen`, `activat`, `3dexperience`) | refuse + report. **No "documented safe choice" is asserted here** — this pass found no vendor documentation of a sign-out-on-exit dialog for this edition, and a sign-out affects the user's platform session, not just this app. Escalate to the user with a background screenshot. |+| Anything unrecognised | `unknown` → `neverAutoAnswer:true` | refuse + report. An unanswerable question is not permission. |++A message rung has one more outcome that is neither "quit" nor "declined":+**blocked**. UIPI failures return success (§2.3), so every message rung reports+`result:"uipiBlocked"` when `GetLastError()` comes back `ERROR_ACCESS_DENIED (5)`,+and the ladder stops there too — an access-denied is a fact about the machine's+integrity levels, not about SOLIDWORKS' willingness to quit, and escalating past it+would just try the same blocked gesture a different way.++The one thing the bridge may do about a dialog is **look**: the census, plus+`swx_screenshot` on `mainHwnd`, whose owned-popup list is the only authoritative+attribution of a dialog to SOLIDWORKS.++And the standing hazard, stated in the code already: on 2026-09-02 closing a+stuck SOLIDWORKS modal through the window manager **took the whole application+down**. That is precisely why (c)/(d) target the **main frame only** and only+when the census says there is no dialog: the same gesture aimed at a modal is a+different act with a different outcome.++### 5.6 Where the code goes++* **The window messages belong to the com host, not to ab.** `swx_status` on the+ dev bridge reports `abAvailable:false`, and ab's close is+ `desktop_ui_window {action:"close"}` — a **UIA `WindowPattern.Close`**, which is+ the mechanism that took the app down on 2026-09-02, and which is unavailable+ whenever ab cannot be reached. The com host already links the `windows` crate+ and already runs on an STA thread with the app's hwnd in hand; `SendMessageTimeout`+ / `PostMessage` there are ~20 lines, synchronous, timeout-bounded and+ measurable. `src/abwin.rs`'s delegation policy ("this bridge owns no window+ code") is right for *screenshots and enumeration* and wrong for the one gesture+ whose exact semantics are the whole point.+* **The census must be readable without ab too.** P3 currently depends on+ `desktop_list_windows`. Add an `EnumWindows` + `GetWindowThreadProcessId`+ census in the com host (cfg(windows)) so "is a dialog up" has an answer when ab+ is unreachable — an unanswerable P3 refuses the quit, so an ab outage would+ otherwise make `swx_stop` permanently impossible.+* **Reads the escape hatch cannot currently make.** `swx_com_eval`'s allowlist+ (`src/comhost/expr.rs`) admits only `READ_MEMBERS` and `get*/is*/has*/can*/enum*`+ prefixes, so `UserControl`, `UserControlBackground`, `Visible`, `FrameState`,+ `CommandInProgress` and `ApplicationType` — every property this design needs to+ *observe* — are refused as "not a read". They are plain property gets. Add them+ to `READ_MEMBERS` (gets only) so the state can be inspected without shipping a+ new verb, and so the experiment in §5.7 can be run read-only.+* **`swCommands_App_Exit = 2825`** goes in a named constant with the source cited,+ not inline.+* **`Frame().GetHWnd()` → `Frame().GetHWndx64()`** (`com.rs:2312`), read as an i64.+ The help says outright that x64-compatible code should use the x64 form; today's+ 32-bit read has simply been lucky that `6622700` fits. Every window rung depends+ on that handle, so a truncated one would aim a close at nothing — or, worse, at+ some other window.+* **The Restart Manager rung needs `RmStartSession` / `RmRegisterResources` /+ `RmGetList` / `RmShutdown` / `RmEndSession`** from `rstrtmgr` — present in the+ `windows` crate behind a feature. `RM_UNIQUE_PROCESS` wants the pid and the+ process creation time as a `FILETIME`, which is exactly what P7 already captures.+ **`RmForceShutdown` must not be reachable from the code at all** — not a runtime+ flag, not an argument: a constant `0` at the call site, with the reason in a+ comment.+* **The exit macro** ships as a reviewed file under `resources/macros/`, is+ matched by content hash before use, and is never generated at runtime.++### 5.7 The experiment protocol++**Preconditions on the human side.** Drew says SOLIDWORKS is free — no work in it+— and gives the go-ahead. This runs on a **bridge-launched** session (so a killed+one cannot be blamed for a bad relaunch), with **0 documents**, and it will END+that session. It must not be run while `openDocs > 0`.++**Instrumented by** `tools/quit-gate.py`, built like `tools/launch-gate.py`: it+talks to the bridge over `adom-bridge … shell_execute`, takes every measurement+from the machine rather than from the bridge's return values, and reports+`UNKNOWN` (exit 3) for any probe it could not read rather than passing by default.++```+tools/quit-gate.py --baseline # read-only: census + logs + journal size + window list+tools/quit-gate.py --rung a # ONE rung, then stop and report+tools/quit-gate.py # the ladder, rung by rung, stopping at the first success+```++**Step 0 — baseline (read-only).** Process census (every image in §0.2, with pids+and creation times); the window census for the SOLIDWORKS pid (hwnd, class,+visible, enabled, `IsHungAppWindow`, `GetMenuState(SC_CLOSE)`); `swx_status` ++`swx_get_app_state` (assert `documentCount == 0`, `unsavedDocuments == 0`,+`busy:false`, `responding:true`); the size and tail of `swxJRNL.swj`; the+contents of `3dexperience-log.json`; `%TEMP%\swx*` directory list; the current+`UserControl` / `Visible` / `ApplicationType` values (needs the §5.6 allowlist+change, else record as UNKNOWN); ledger tail.++**Step 1 — prove the 0-document claim.** `swx_start` a fresh session if needed,+close nothing, and confirm `documentCount == 0` from `documents()` (count-agreed,+not merely an empty array). **This is the step that settles the open question in+§0.5.** Record it in the ledger as `solidworks_quit_experiment_baseline`.++**Step 2 — rung (a).** `swx_stop {maxRung:"a", confirm:true, reason:"…"}` with the+process opt-in on. Record: what `ExitApp` returned or which HRESULT it failed+with; whether `UserControl` was readable and what it was before/after; the+reference-release order actually executed; then the full §5.4 measurement set at++2 s, +5 s, +10 s, +30 s.++**Step 3 — only if (a) left the process alive and raised no window:** rung (b),+then (c), (d), (e), (f), one call each, full measurement set after each. Stop at the+first rung whose process-gone signal is true. Stop **permanently** at the first new+top-level window, and at any `uipiBlocked`.++**Step 4 — the aftermath, whatever happened.** Whether or not it quit: journal+tail; child-process census; platform log; `%TEMP%\swx<pid>`; crash-reporter+window and artefact check; `swx_status` (should report `running:false`).++**Step 5 — the relaunch.** `swx_start` and record the launch-state-machine+timings: route, `launcherStarted`, `Run Begin` latency, `sldworks.exe` latency,+COM-attach latency, and whether any stall was detected. Compare against the+2026-09-08 healthy launch (`Run Begin` +2 s, `sldworks.exe` +5 s, attach 1–3 min)+and against the stall signature in `docs/LAUNCH-RESILIENCE.md` §"FAILURE MODE A".+**This is the rung-quality test that matters most operationally**: a quit that+costs the next launch is not a graceful quit.++**What the gate will settle**++| # | Question | If yes | If no |+|---|---|---|---|+| 1 | With `Some(0)` documents, does `ExitApp` (+ `UserControl=false` + full release) quit it? | v2 is rung (a) alone; the ladder ships pinned at `maxRung:"a"` and the rest is dead code we keep | the reported decline is real at 0 documents, which is a finding worth publishing — nobody else has reported it |+| 2 | Does the process-gone signal ever disagree with `GetActiveObject`? | defect 2/3 are confirmed and v1's "declined" verdicts were partly self-inflicted | the ROT check was fine and we lost nothing by replacing it |+| 3 | Does rung (b) `RunCommand(2825)` quit it? | the UI command path works where the API does not — a genuinely new result for this edition | the app's own dispatcher declines too, which points at the platform session, not the API |+| 4 | Does rung (c) `WM_CLOSE` quit it, and does the journal show `ExitApp` + `End Sub`? | the §0.3 reading is confirmed: a window close runs the documented exit path on Connected | the 15:24 shutdown came from something else and §0.3 must be rewritten |+| 4b | Does rung (e), the in-process macro, quit it where every out-of-process route failed? | the decline is about **who is asking**, not about the app — which would be the most interesting result in this document, and would also mean `RunMacro2` is live-proven | the app itself is refusing, and the cause is the platform session |+| 4c | Does rung (f), the Restart Manager without force, quit it — and does it come back `ERROR_FAIL_SHUTDOWN` with a readable `AppStatus` if not? | there is an OS-sanctioned, never-killing route that works, and the reason for a refusal is machine-readable | the app declines the OS's own shutdown request too, which is close to conclusive that only a human gesture will do it |+| 5 | Are the per-session children reaped and the platform daemons untouched? | "graceful" is measurable and we can assert it | whichever rung did that is downgraded, however fast it was |+| 6 | Does the next launch behave exactly as a launch after a user-driven exit? | quitting is operationally safe to offer | it is not, and `swx_stop` must say so in its hint |+| 7 | Does any rung raise a dialog with 0 documents (sign-out, session, survey)? | the classification in §5.5 gets its real titles and the refusal path is exercised for real | this edition exits silently at 0 documents |++**What the gate cannot tell us**, and must not be read as telling us: that a rung+which worked once works always (one machine, one tenant, one hotfix level); that+the ladder is safe with documents open (it is never run that way); and whether+rung (e) is safe in general (it runs VBA inside the user's session — the shipped+macro is two lines and hashed, and that is the whole of the argument).++### 5.8 The response shape++```json+{+ "success": true, "stopped": true, "rung": "c",+ "rungsTried": [+ {"rung":"a","action":"UserControl=false; ExitApp; released 2 references",+ "result":"declined","processGone":false,"newWindows":0,"elapsedSec":30.4,+ "detail":"ExitApp returned S_OK; pid 107688 still present at +30s"},+ {"rung":"b","action":"RunCommand(swCommands_App_Exit=2825)","result":"declined","…":"…"},+ {"rung":"c","action":"SendMessageTimeout(WM_CLOSE) -> hwnd 6622700","result":"quit",+ "processGone":true,"exitLatencySec":6.2,+ "childrenReaped":["sldProcMon","sldsurfacing","sldimport","swCefSubProc x10"],+ "expectedSurvivorsAlive":["ENOPLMCSAClient","EdmServerV6","3DEXPERIENCELauncherSysTray","3DEXPERIENCELauncher"],+ "journalLockReleased":true,+ "journalClosed":true,"platformReleased":true,"crashReporter":false,"newWindows":0}+ ],+ "preconditions": {"openDocuments":0,"unsavedDocuments":0,"dialogs":0,+ "busy":false,"responding":true,+ "processId":107688,"processCreatedAt":1788899741,"mainHwnd":6622700},+ "windowEtiquette":"background",+ "_hint":"…"+}+```++`stopped:false` stays a **failure** with `errorCode:"host_app_stop_declined"`,+carrying `rungsTried` so the caller can see what was actually attempted, and the+hint keeps saying what it says today: do **not** reach for `swx_kill`, and on a+Connected edition a quit is close to a one-way door.++Two error codes are new and load-bearing: `open_documents_present` (P1 failed —+distinct from `unsaved_documents`, and NOT waivable by `force`) and+`save_prompt_open` (§5.5).++### 5.9 What the suite has to prove (before the gate, on this Linux host)++The repeated lesson of this project is *"the mock modelled the easy case and hid a+real defect"* — four times now. So the mock backend must be able to model the hard+cases before any of this ships:++| Mock capability to add | The defect it makes testable |+|---|---|+| `SWX_MOCK_EXITAPP_DECLINES=1` — `ExitApp` returns cleanly and the app stays up | the whole ladder. Today the mock always quits on rung (a), so rungs (b)–(e) can never be reached by a test. |+| a simulated window census, with an injectable dialog | P3, and the "a dialog stops the ladder" rule |+| `openDocuments` and `unsavedDocuments` independently settable, including UNKNOWN | P1 vs P2 — the current gap where 5 clean documents pass |+| a rung-by-rung effect script ("declines a, declines b, quits on c") | that `rung` in the response is the rung that actually worked, and that the ladder stops on first success |++Tests that must exist, named for what they prove:++* `a_session_with_open_documents_is_never_quit` — 5 clean documents, `force:true`,+ → `errorCode:"open_documents_present"`. **This is the regression test for today's+ live posture.**+* `force_waives_the_unsaved_question_and_nothing_else` — `force:true` cannot pass+ P1, P3, P4, P5 or P7.+* `a_dialog_stops_the_ladder_and_is_never_answered` — a `save_changes` window+ injected between rungs: the ladder stops, reports the titles, closes nothing.+* `the_ladder_stops_at_the_first_rung_that_worked` — no rung runs after success.+* `a_quit_that_did_not_quit_is_a_failure_with_every_rung_measured` — the v1+ honesty property, extended to `rungsTried`.+* `a_refusal_is_recorded_in_the_ledger` — `solidworks_quit_refused` with the+ precondition that failed (v1 records nothing).+* `the_verification_releases_every_reference_before_it_asks` — a com-host-level+ test that the reference count our process holds is zero before the census runs.+* `nothing_in_the_stop_path_ever_calls_kill` — a source-level *and* behavioural+ assertion that no rung reaches `taskkill`, `TerminateProcess`, `swx_kill`, or+ `RmForceShutdown`.+* `a_blocked_message_is_reported_as_blocked_not_declined` — a simulated+ `ERROR_ACCESS_DENIED` on a message rung → `result:"uipiBlocked"`, ladder stops.+ (UIPI failures return *success*, so an unchecked `GetLastError` would report a+ quit that never happened — §2.3.)+* `the_expected_survivors_are_not_treated_as_a_failure` — a census in which+ `ENOPLMCSAClient` / `EdmServerV6` / `3DEXPERIENCELauncher*` are still alive after+ the pid is gone still reports a clean quit (§2.6).+* the verb-coverage gate keeps working: any new arg (`maxRung`) is described by+ `swx_describe` and listed in `bridge.json`.++### 5.10 Two other things this investigation found in the existing code++Neither is about quitting, both belong to the same posture:++1. **`swx_kill` kills by IMAGE NAME.** `taskkill /F /IM SLDWORKS.exe`+ (`src/verbs3.rs:648`). The launch path spent three adversarial rounds+ establishing that a pid without a verified creation time is "not ours" and that+ there must be *no image-name kill anywhere* — and then the kill verb does+ exactly that, which on a machine with two SOLIDWORKS sessions would end the+ wrong one. If the desperate path is kept it should target the attributed pid,+ and the mature field pattern (myCAD, §3.4) is to tag your own instance's+ `MainWindowTitle` first and kill only the match.+2. **The com host disowns every bridge-created document BEFORE it checks whether+ the quit worked.** In `com.rs`'s `"stop"` arm the order is `ExitApp` →+ `self.sw = None` → `self.created.clear()` → `created_store::clear()` → *then*+ verify. On a DECLINED quit SOLIDWORKS is still up with the bridge's own+ documents open, and the host's independent veto now believes it created none of+ them — so the next `swx_save` / `swx_close_doc` on one of them is refused with+ *"this document was not created by this bridge"*. That is bug #12's shape+ exactly, reachable through the failure path, and it also puts the two vetoes+ into disagreement (the server's `owned-docs.json` is correctly left alone —+ `verbs.rs` only clears on the success arm). **Clear the host registry after a+ census-confirmed exit, never before the verification.**++---++## 6. This environment: why the BRIDGE is the right place to do this++### 6.1 What the classifier blocks, and why that is correct++Auto-mode's classifier refuses close/kill-class actions aimed at SOLIDWORKS when+they are issued from the agent's shell. Measured today: a+`Process.CloseMainWindow()` over `adom-bridge shell_execute` was **denied**. Every+read-only probe in §0 went through on the first attempt with a stated reason.++That boundary is not an obstacle to work around, and this document does not+propose a way around it. A raw shell command aimed at a live CAD session:++* carries no preconditions — nothing checked the document count, the dirty flags,+ the dialog census or whether the app was hung;+* carries no identity — `Get-Process sldworks | …` is an image-name match, and the+ launch path already learned that lesson the expensive way (*no handle, no+ kill*, `docs/LAUNCH-RESILIENCE.md` §8b);+* is measured by nothing — the shell reports what it *sent*, never what happened;+* leaves no audit anywhere the user can see.++A one-line PowerShell that can end a CAD session with unsaved work in it is+indistinguishable, from the outside, from the same line typed by mistake. The+classifier is right to refuse it, and the fix is not a louder reason string.++### 6.2 Why the bridge is different++The bridge is not a shortcut past that gate — it is the thing that supplies+everything the shell command is missing, and it is the *only* place on the+machine where all of it exists at once:++| The shell has none of this | The bridge has it, today |+|---|---|+| a document count you can trust | `documents()` — an empty enumeration is believed only when `GetDocumentCount` agrees, and an unreadable count refuses (`docs_enumeration_failed`) |+| a dirty-flag census | `GetSaveFlag` per document, with "unreadable" treated as dirty |+| a dialog census with attribution | `swx_dismiss_blocking_dialogs` / `swx_window_info`, `ownerConfirmed` only for a proven owner pid, `neverAutoAnswer` classes |+| process identity that survives pid reuse | pid **plus** creation time, the same pair the kill allowlist uses |+| a human's explicit consent | `writes::authorize_in(Kind::EndSession)` — process-level opt-in **and** `confirm:true` **and** a free-text `reason`, enforced independently in the server and again in the com host |+| an audit the user can read | `ledger.jsonl` → `swx_action_ledger`, atomic writes, bounded |+| honest failure | `host_app_stop_declined` rather than `success:true` on a quit that did not happen |+| a bounded blast radius | one verb, one gesture, aimed at one hwnd belonging to one attributed pid — never an image-name match |++So the request "quit SOLIDWORKS" should reach exactly one place: `swx_stop`, with+a `reason`, on a session whose state has been re-measured in the same call. The+agent's job is to ask for it and to relay the refusal when it comes; the bridge's+job is to refuse most of the time.++**And the corollary, which matters just as much:** `swx_stop` must not become a+way to get a close past the classifier by wrapping it. That is what §5.2's+preconditions are for, why `force` is narrowed to waive P2 only, why the ladder+ships pinned at `maxRung:"a"`, and why the ladder stops at (f) with no kill of any+kind reachable from it — `RmForceShutdown` included.++### 6.3 The audit every quit leaves++One ledger line per **attempt**, not per success — a refusal is the more+interesting event:++| Action slug | Written when | Detail |+|---|---|---|+| `solidworks_quit_requested` | the gates pass, before the first rung | caller (aiThread/container), reason, pid + creation time, mainHwnd, openDocuments, unsavedDocuments, dialogCount, `maxRung` |+| `solidworks_quit_rung` | after **every** rung | rung id, action taken, elapsed, processGone, newWindows, journalClosed, childrenReaped, daemonsUntouched |+| `solidworks_stopped` | the process is gone, confirmed by census | winning rung, exit latency, the full clean-exit signal set |+| `solidworks_stop_declined` | the ladder ran out | every rung's measurement, the last HRESULT, whether a dialog appeared |+| `solidworks_quit_refused` | a precondition or the write gate said no | **which** precondition, and its measured value (this is the line v1 never writes) |++Plus, unchanged: `swx_verb_times` records the duration so the next quit can be+paced honestly, and the ownership registries (`owned-docs.json`,+`com-host-created.json`) are cleared **only** on a census-confirmed exit — v1+clears them whenever the host answers, which on a declined quit disowns documents+that are still open.++Nothing in this document authorises a quit. The user does, per call, with the+opt-in and the reason — and the bridge still refuses if the session it can+actually measure is not the one the caller assumed.++---++## 7. Next actions, in order++1. **Do not run any of this yet.** As of writing, Drew is working in the session+ (`openDocs 5`, journal recording `Save3` and `LoadFile2` calls minutes ago).+2. **Fix the two things that are wrong today regardless of the ladder** — the+ `openDocuments == Some(0)` precondition (§5.1 defect 1) and the com host+ disowning documents before it verifies (§5.10.2). Both are small, both are+ testable on this Linux host with the mock, and the first one is currently a+ loaded gun.+3. **Make the state observable, and fix the handle**: add the property gets to+ `expr.rs::READ_MEMBERS` (§5.6), fix the `IsPLMConnected` example (that member does+ not exist on this build), and switch `Frame().GetHWnd()` to+ `Frame().GetHWndx64()` read as an i64 — every window rung aims at that handle.+4. **Build `swx_stop` v2 pinned at `maxRung:"a"`** with the full precondition+ sweep, the reference-release order, census-based verification and the ledger+ lines — plus the mock knobs and tests in §5.9.+5. **Write `tools/quit-gate.py`** to the shape of `tools/launch-gate.py`, and take+ its `--baseline` read-only.+6. **Ask Drew for a window** when SOLIDWORKS is free, then run steps 1–5 of §5.7 on+ a bridge-launched, 0-document session. Rung (a) first. Record everything.+7. **Only then** arm rungs (b)–(e), one per run, in that order.+8. Update `SKILL.md` (`swx_stop`'s documented behaviour), `README.md` and+ `docs/ECOSYSTEM-TABLE.md` row 1 with whatever the gate actually proves — and+ note that the row's current claim ("`ExitApp` DECLINED live on Connected") is+ not yet backed by a ledger entry on this machine (§0.5).++## 8. Sources++**Win32 / .NET semantics** (Microsoft Learn, fetched directly):+[`WM_CLOSE`](https://learn.microsoft.com/en-us/windows/win32/winmsg/wm-close) ·+[`WM_SYSCOMMAND`](https://learn.microsoft.com/en-us/windows/win32/menurc/wm-syscommand) ·+[`WM_QUIT`](https://learn.microsoft.com/en-us/windows/win32/winmsg/wm-quit) ·+[`SendMessage`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessage) ·+[`SendMessageTimeout`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeouta) ·+[`PostMessage`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-postmessagea) ·+[`PostThreadMessage`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-postthreadmessagea) ·+[`DestroyWindow`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-destroywindow) ·+[`SendInput`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendinput) ·+[`SetForegroundWindow`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow) ·+[`EndTask`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-endtask) ·+[`ShutdownBlockReasonCreate`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-shutdownblockreasoncreate) ·+[UIPI](https://learn.microsoft.com/en-us/archive/blogs/luisdem/uipi-user-interface-privilege-isolation) ·+[`Process.CloseMainWindow`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.closemainwindow) ·+[its implementation](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Windows.cs) ·+[`MainWindowHandle`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.mainwindowhandle) ·+[Chen — you can't simulate keyboard input with PostMessage](https://devblogs.microsoft.com/oldnewthing/20250319-00/?p=110979)++**MFC shutdown path** (this is what makes `WM_CLOSE` == File ▸ Exit):+[TN022 Standard Commands Implementation](https://learn.microsoft.com/en-us/cpp/mfc/tn022-standard-commands-implementation?view=msvc-170) ·+[Destroying Frame Windows](https://learn.microsoft.com/en-us/cpp/mfc/destroying-frame-windows?view=msvc-170) ·+[`CWinApp::SaveAllModified`](https://learn.microsoft.com/en-us/cpp/mfc/reference/cwinapp-class?view=msvc-170) ·+[`CDocument::CanCloseFrame` / `SaveModified`](https://learn.microsoft.com/en-us/cpp/mfc/reference/cdocument-class?view=msvc-170) ·+[TN001 window class registration](https://learn.microsoft.com/en-us/cpp/mfc/tn001-window-class-registration?view=msvc-170) ·+[KB Q77135 — Different Ways to Close an Application](https://jeffpar.github.io/kbarchive/kb/077/Q77135/)++**Restart Manager** (rung f):+[Guidelines for Applications](https://learn.microsoft.com/en-us/windows/win32/rstmgr/guidelines-for-applications) ·+[`RmRegisterResources`](https://learn.microsoft.com/en-us/windows/win32/api/restartmanager/nf-restartmanager-rmregisterresources) ·+[`RmShutdown`](https://learn.microsoft.com/en-us/windows/win32/api/restartmanager/nf-restartmanager-rmshutdown) ·+[`WM_QUERYENDSESSION`](https://learn.microsoft.com/en-us/windows/win32/shutdown/wm-queryendsession) ·+[`WM_ENDSESSION`](https://learn.microsoft.com/en-us/windows/win32/shutdown/wm-endsession) ·+[Task Scheduler `AllowHardTerminate`](https://learn.microsoft.com/en-us/windows/win32/taskschd/tasksettings-allowhardterminate) (the OS's own WM_CLOSE-then-terminate pattern)++**The frame handle:**+[`IFrame::GetHWnd`](https://help.solidworks.com/2023/english/api/sldworksapi/SolidWorks.Interop.sldworks~SolidWorks.Interop.sldworks.IFrame~GetHWnd.html) ·+[`IFrame::GetHWndx64`](http://help.solidworks.com/2014/English/api/sldworksapi/SolidWorks.Interop.sldworks~SolidWorks.Interop.sldworks.IFrame~GetHWndx64.html) ·+[CodeStack — the Frame object](https://www.codestack.net/solidworks-api/application/frame/) ·+[CodeStack — driving PropertyManager windows with Win32](https://www.codestack.net/solidworks-api/application/frame/property-manager-page-windows-api/) ·+[CodeStack — missing API command, use WM_COMMAND](https://blog.codestack.net/missing-solidworks-api-command)++**Connected exit behaviour:**+[Saving Modified Documents (SWConnected help)](https://help.solidworks.com/2025/english/SWConnected/swdotworks/c_saving_modified_documents.htm) ·+[Save Modified Documents dialog (`HIDD_NEW_CLOSEALL_DLG`)](https://help.solidworks.com/2018/English/SolidWorks/sldworks/HIDD_NEW_CLOSEALL_DLG.htm) ·+[Save to 3DEXPERIENCE dialog](https://help.solidworks.com/2025/english/SWConnected/swdotworks/r_SaveDialogBox.htm) ·+[Status Feedback (SWConnected)](https://help.solidworks.com/2025/english/SWConnected/swdotworks/c_status_feedback.htm) ·+[Error Report dialog (SWConnected)](https://help.solidworks.com/2025/English/SWConnected/swdotworks/c_SW_error_report_db.htm) ·+[Sign out of the 3DEXPERIENCE platform](https://help.solidworks.com/2018/english/swconnection/t_sign_out_of_3DEXPERIENCE_platform.htm) ·+[GoEngineer — the 3DEXPERIENCE login box](https://www.goengineer.com/blog/understanding-the-3dexperience-login-box-in-solidworks) ·+[MySession architecture SolidPractices](https://3dswym.3dexperience.3ds.com/wiki/solidworks-news-info/understanding-solidworks-my-session-architecture-and-optimizing-performance-solidpractices_bJFmXC4ZTxedM2WKrd0kUw) ·+[3DEXPERIENCE Launcher services](https://www.3ds.com/support/documentation/resource-library/3dexperience-launcher-services) ·+[Makers-support wiki — errors and installation issues](https://3dswym.3dexperience.3ds.com/wiki/makers-support/comprehensible-guide-to-3dexperience-solidworks-for-makers-sw-design-errors-and-installation-issues_zEjFNZPPQh2K8AxvHJETbQ) ·+[Locking and unlocking files](https://help.solidworks.com/2018/English/swconnection/t_locking_and_unlocking_files.htm)++**Costs of a non-graceful exit:**+[GoEngineer — top three causes of the journal-file warning](https://www.goengineer.com/blog/top-three-causes-solidworks-journal-file-warning) ·+[Solid Solutions — recovering files after a crash](https://www.solidsolutions.co.uk/blog/2023/04/how-to-recover-solidworks-files-after-a-crash/) ·+[GoEngineer — recovering a hung network licence](https://www.goengineer.com/blog/solidworks-recovering-hung-network-license) ·+[Background Downloader persists by design](https://help.solidworks.com/2024/english/Installation/install_guide/r_background_downloader.htm) ·+[Blue Byte — SOLIDWORKS command-line switches (incl. the undocumented `/SWDisableExitApp`)](https://bluebyte.biz/solidworks/solidworks2022/how-to-launch-solidworks-3d-using-the-solidworks-command-line/)++**Official API help** (JS shell to every fetcher; quoted via search snippets):+[`ISldWorks::ExitApp`](https://help.solidworks.com/2022/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~exitapp.html) ·+[`ISldWorks::UserControl`](https://help.solidworks.com/2023/English/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.ISldWorks~UserControl.html) ·+[`ISldWorks::RunCommand`](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~runcommand.html) ·+[`swCommands_e`](https://help.solidworks.com/2025/english/api/swcommands/solidworks.interop.swcommands~solidworks.interop.swcommands.swcommands_e.html) ·+[SOLIDWORKS Connected API guide](https://help.solidworks.com/2026/english/api/sldworksapiprogguide/Overview/SOLIDWORKS_Connected.htm) ·+[`ISldWorks::CloseAllDocuments`](https://help.solidworks.com/2023/english/api/sldworksapi/SolidWorks.Interop.sldworks~SolidWorks.Interop.sldworks.ISldWorks~CloseAllDocuments.html) ·+[`ISldWorks::QuitDoc`](https://help.solidworks.com/2016/english/api/sldworksapi/SOLIDWORKS.Interop.sldworks~SOLIDWORKS.Interop.sldworks.ISldWorks~QuitDoc.html) ·+[Task Scheduler overview](https://help.solidworks.com/2026/English/SolidWorks/sldworks/c_SOLIDWORKS_Task_Scheduler_Overview.htm)++**Negative results worth recording** (searched for, not found — so nobody re-derives+them): no primary source for the frame's window class name (`"SW_Main"` is+folklore); no public report of `ExitApp` being declined on a Connected/Makers+edition; no documented exit-time dialog for a **zero-document** Connected session;+no documented four-button "Save all to This PC / Save all to 3DEXPERIENCE" exit+dialog; no documented function for `SWXDesktopHelper.exe` / `SWXDesktopLauncher.exe`+anywhere; `swSendToService.exe` is not attested and probably does not exist.++**Command ids:** [`tdsmith/swharness/swcommands.py`](https://github.com/tdsmith/swharness/blob/master/swcommands.py) — `swCommands_App_Exit = 2825`, `swCommands_FileClose = 2789` (fetched and read directly).++**ExitApp not shutting the process down:** [3DSwym — "Why I can't Shut down the sldworks process using ExitApp Method"](https://3dswym.3dexperience.3ds.com/question/solidworks-user-forum/why-i-can-t-shut-down-the-sldworks-process-using-exitapp-method-isldworks_RXWbgG0RQXGTbutM63BajA) ·+[3DSwym — "cancel exit SW"](https://3dswym.3dexperience.3ds.com/question/solidworks-user-forum/cancel-exit-sw_SN-g84inS9q5reGweuAr4w) (an add-in cannot veto shutdown) ·+[myCAD — "Kill SLDWorks.exe -Embedding"](https://forum.mycad.visiativ.com/t/tuer-sldworks-exe-embedding/106333?locale=en) (ExitApp alone leaves the process; title-tagging is the mature targeted-kill pattern) ·+[CATI — "Killing the SOLIDWORKS Task Scheduler!"](https://www.cati.com/blog/killing-the-task-scheduler/)++**Ecosystem code, read directly:**+[xarial/xcad `SwApplication.cs`](https://github.com/xarial/xcad/blob/master/src/SolidWorks/SwApplication.cs) (the correct exit ordering) ·+[CAD-Booster/solidworks-api](https://github.com/CAD-Booster/solidworks-api) (in-process code must not dispose the app) ·+[eyfel/mcp-server-solidworks](https://github.com/eyfel/mcp-server-solidworks) ·+[vespo92/SolidworksMCP-TS](https://github.com/vespo92/SolidworksMCP-TS) ·+[andrewbartels1/SolidworksMCP-python](https://github.com/andrewbartels1/SolidworksMCP-python) ·+[alisamsam/Solidworks-MCP](https://github.com/alisamsam/Solidworks-MCP) ·+[Adam-Schildkraut/solidworks-mcp](https://github.com/Adam-Schildkraut/solidworks-mcp) ·+[deloarts/pyswx](https://github.com/deloarts/pyswx) (`exit_app`, `quit_doc`, `user_control`) ·+[ros/solidworks_urdf_exporter `SwAddin.cs`](https://github.com/ros/solidworks_urdf_exporter/blob/master/SW2URDF/SW/SwAddin.cs) ·+[codestack — stand-alone applications](https://www.codestack.net/solidworks-api/getting-started/stand-alone/)++**Connected specifics:** [CADmunity — Using SOLIDWORKS APIs with SOLIDWORKS Connected](https://forum.cadmunity.com/t/using-solidworks-apis-with-solidworks-connected-3d-experience/5439) ·+[swyftsol — How to Exit 3DEXPERIENCE PLM Services](https://support.swyftsol.com/knowledge-base/how-to-exit-3dexperience-plm-services)++**In this repo:** `docs/LAUNCH-RESILIENCE.md` (the attribution law, the kill+allowlist, the launch state machine) · `docs/FUSION-PARITY-BRIEF.md` §"The gate was+closed in the wrong direction" (the only ledger-backed decline we have) ·+`docs/_fusion-inventory.md` line 29 (`fusion_stop` = clean close + WM_CLOSE) ·+`docs/ECOSYSTEM-TABLE.md` row 1 · `src/verbs.rs:1008` · `src/comhost/com.rs:1293`+· `src/abwin.rs` · `src/comhost/expr.rs`.++**Machine evidence** (all read-only, 2026-09-08, `adom_drew2`): process census+(`Win32_Process`), window census (`EnumWindows`/`GetClassName`/`IsHungAppWindow`/+`GetMenuState`), `swx_status`, `swx_get_app_state`, four `swx_com_eval` reads,+`%APPDATA%\DassaultSystemes\SWXDesktopLauncher\*.log`,+`%APPDATA%\DassaultSystemes\SWXDesktopHelper\*.log`,+`%APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\{swxJRNL.swj,swxJRNL.BAK,3dexperience-log.json,SWStdSessionsData.xml}`,+`%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\ledger.jsonl`, `%TEMP%\swx*`,+`HKCU\Software\SolidWorks\*`.