← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.2+0.9.3
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.2"+version = "0.9.3" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.2"+version = "0.9.3" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+34−1
@@ -458,7 +458,40 @@ not.  ## Roadmap -- v0.9.2 (this): **two versions of the ledger or the ownership registry are+- v0.9.3 (this): **attached is not ready — and a declined quit is about the+  session's history.** Both measured live on 2026-10-01.++  Document commands sent 8 s after COM attach CRASHED SOLIDWORKS while its+  3DEXPERIENCE add-in was still initialising; a control launch that sent none+  crashed nothing. So a READY stage now follows `attached`: COM+  `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker (after+  the last LOAD_ADDIN line) must agree where they can be read; with neither, a+  60 s grace stands in and says so; nothing is ever promoted to ready by the+  clock. `swx_start` reports `stage:"ready"` only then, `swx_status` exposes+  `solidworks.ready` and what it rests on, and fifteen document and quit verbs+  refuse `host_app_starting` with `retryAfterSeconds` until it is true. A sweep+  during which SOLIDWORKS dies now says `host_app_gone` — preserved and closed+  counts, and the session's crash markers from the performance log — instead of+  `dialog_census_unknown`; SOLIDWORKS' `~$` lock files are listed apart.++  `swx_stop` rung (a) was declined three times on one session that had never held+  a document, and quit in 3.4 s once a scratch part had been created and closed+  in it — reconciling 09-08 and 09-17. The bridge now records per session whether+  it has ever held a document, and under `userAuthorized`, on an empty session+  with no dialog, PRIMES a declined one (one bridge-owned scratch part, ledgered+  `quit_prime`) and asks again. The quit gate's "no longer running" claim had+  passed vacuously after a decline; it now requires `running:false` and is+  self-tested from the suite. The gates retry ab's fast empty `shell_execute`+  failure up to three times, 2 s apart (it came twice in a row), and the launch+  gate probes nothing before READY.++  Adversary pass 41: the ledger merge is a MULTISET now (two identical lines in+  one file are two events — max of the two counts, never fewer), every ledger+  line carries a monotonically increasing `seq`, and the cap never cuts silently:+  the live ledger is trimmed only past twice the cap, and the lines it removes go+  to `ledger-archive-<stamp>.jsonl` with a `ledger_archived` line saying how many+  and where. 448 tests.+- v0.9.2: **two versions of the ledger or the ownership registry are   MERGED, not picked; a same-size, same-mtime rewrite is caught by its hash; and   a recovery folder that cannot be made is refused once, before the first save**   (adversary pass 40; sev-1 count zero, all seven of its mutants caught).
SKILL.md+6−3
@@ -61,9 +61,12 @@ BEFORE you do it:    `swx_import` fails with `interconnect_blocks_import`, that is the whole    answer: tell the user to uncheck Tools > Options > System Options > Import >    "Enable 3D Interconnect" and import again.-6. **Start SOLIDWORKS only with `swx_start`, and poll it.** A cold start of the-   Connected edition takes 1-3 minutes; `stillLaunching:true` is not a failure,-   and calling `swx_start` again attaches instead of launching twice.+6. **Start SOLIDWORKS only with `swx_start`, and poll it until `stage:"ready"`.**+   A cold start of the Connected edition takes 1-3 minutes; `stillLaunching:true`+   is not a failure, and calling `swx_start` again attaches instead of launching+   twice. ATTACHED IS NOT READY: document commands sent too early crashed+   SOLIDWORKS (measured 2026-10-01), so document verbs refuse `host_app_starting`+   until `solidworks.ready` is true — wait `retryAfterSeconds` and call again.    `stage:"helperSignIn"` means a person must sign in — only the user can. Never    launch `sldworks.exe` or the launcher any other way. 7. **Name the document you mean.** Pass `targetDocument` (a title) or `path` on
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.2",+  "version": "0.9.3",   "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",
docs/ECOSYSTEM-TABLE.md+3−2
@@ -109,15 +109,16 @@ templates**, alisamsam **22**. | 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. **0.5.3**: the resolved `%TEMP%` is the WHOLE location proof (8.3-aware, no shape-test fallback), and with no resolved root the drain refuses (`temp_root_unknown`) instead of closing nothing quietly. **0.5.4**: the report says which root it compared against (`tempRoot`) and counts documents that carry the launcher's tail but sit elsewhere (`importTempsElsewhere` + a sample), so a drain that matched nothing can no longer claim it drained everything. **0.5.6**: the victim is CLOSED by the path it was chosen by (it was closed by TITLE, which `CloseDoc` resolves by name without prompting — a live session held temps sharing a saved file's title), a shared title is refused and reported, and every close is verified by path — the mismatch case is real, because ab's `%TEMP%` need not be the one SOLIDWORKS used | ✗ | ✗ | ✗ | ✗ | ✗ |-| 44 | **Graceful QUIT that is measured, not assumed** (never a kill, never answers a dialog, proves the exit from the process census) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (rung a)** — `swx_stop {userAuthorized, maxRung:"a"}` quit a bridge-launched session on the FIRST rung (UserControl=false; ExitApp; every COM reference released), verified absent from the process census; `swx_shutdown` then removed a stray empty instance (pid 145188) and `tasklist` showed no `sldworks.exe` at all. **`ExitApp` is NOT declined at zero documents on this edition** — the long-held `host_app_stop_declined` belief was a SAVE-PROMPT STALL misread as a refusal, which is why the ladder was built pinned at rung (a) and why the preserve-then-close sweep is what actually unblocked it. A rung ladder (a `ExitApp` → f Restart Manager *without* `RmForceShutdown`), pinned at rung (a); 7 re-measured preconditions where UNKNOWN refuses and `force` waives the UNSAVED question ONLY; every COM reference released before and after each rung, with outstanding refs named in the report; success = *this pid AND this creation time* absent from the census, never `GetActiveObject`; per-session children reaped while the documented platform daemons are left alone; a dialog stops the ladder permanently and is never answered; nothing disowned until the exit is verified. every precondition re-measured before EVERY rung (a mid-ladder refusal is a real outcome), the exit POLLED to the rung's budget rather than sampled once (a real `ExitApp` returns before the process goes), the dialog census covering the `swCefSubProc` web-view children by parentage, the count cross-checked against the enumeration, and the message rungs re-verifying window ownership before they send. `tools/quit-gate.py` proves the session was bridge-launched, and **its FULL CYCLE PASSED LIVE 2026-09-30 through the bridge ab hosts**: rung (a), process gone 29.5 s, per-session servers reaped, nothing on screen, no crash reporter, every rung reported, and SOLIDWORKS relaunched by the bridge 54.7 s later. The gate now reaches the bridge through ab's verb routing (the side-loaded dev copy on 24695 is terminated by ab 2.1.163 within 30 s) and passes `userAuthorized:true` — the standing authorization — rather than the write-gate pair, which the hosted bridge rightly never enables | ✗ | ✗ | ✗ | ✗ | ✗ |+| 44 | **Graceful QUIT that is measured, not assumed** (never a kill, never answers a dialog, proves the exit from the process census) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (rung a)** — `swx_stop {userAuthorized, maxRung:"a"}` quit a bridge-launched session on the FIRST rung (UserControl=false; ExitApp; every COM reference released), verified absent from the process census; `swx_shutdown` then removed a stray empty instance (pid 145188) and `tasklist` showed no `sldworks.exe` at all. **`ExitApp` is NOT declined at zero documents on this edition** — the long-held `host_app_stop_declined` belief was a SAVE-PROMPT STALL misread as a refusal, which is why the ladder was built pinned at rung (a) and why the preserve-then-close sweep is what actually unblocked it. A rung ladder (a `ExitApp` → f Restart Manager *without* `RmForceShutdown`), pinned at rung (a); 7 re-measured preconditions where UNKNOWN refuses and `force` waives the UNSAVED question ONLY; every COM reference released before and after each rung, with outstanding refs named in the report; success = *this pid AND this creation time* absent from the census, never `GetActiveObject`; per-session children reaped while the documented platform daemons are left alone; a dialog stops the ladder permanently and is never answered; nothing disowned until the exit is verified. every precondition re-measured before EVERY rung (a mid-ladder refusal is a real outcome), the exit POLLED to the rung's budget rather than sampled once (a real `ExitApp` returns before the process goes), the dialog census covering the `swCefSubProc` web-view children by parentage, the count cross-checked against the enumeration, and the message rungs re-verifying window ownership before they send. `tools/quit-gate.py` proves the session was bridge-launched, and **its FULL CYCLE PASSED LIVE 2026-09-30 through the bridge ab hosts**: rung (a), process gone 29.5 s, per-session servers reaped, nothing on screen, no crash reporter, every rung reported, and SOLIDWORKS relaunched by the bridge 54.7 s later. The gate now reaches the bridge through ab's verb routing (the side-loaded dev copy on 24695 is terminated by ab 2.1.163 within 30 s) and passes `userAuthorized:true` — the standing authorization — rather than the write-gate pair, which the hosted bridge rightly never enables. **MEASURED 2026-10-01: the decline is about session HISTORY** — rung (a) was declined three times on a session that had never held a document and quit in 3.4 s once one had been created and closed; since 0.9.3 the ladder PRIMES such a session (empty, no dialog, user-authorized only) and asks again, and the gate's "no longer running" claim — which had passed vacuously after declines — now requires `running:false` and is self-tested | ✗ | ✗ | ✗ | ✗ | ✗ |  | 48 | **A title collision between SAVED documents is impossible here — measured, not assumed** | ✗ | **✓ MEASURED LIVE 2026-09-17** — `tools/drain-gate.py --fixture new` copied a bridge-created document's own file to a second directory under the same basename and called `swx_open` on it. SOLIDWORKS REFUSED: `errors = 65536` (`swFileWithSameTitleAlreadyOpen`). So the drain's title-collision guard — which refuses to close any document whose title another open document shares, and names both paths — can only ever fire for PATHLESS documents, which is exactly the state identity-at-birth removes. The guard stays (a document the bridge did not create can still be pathless, and an older bridge's leftovers are), and the measurement is recorded next to it in `com.rs` so nobody "simplifies" it on the assumption that saved collisions are the common case. No MCP server measures this at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; fifteen document and quit verbs refuse `host_app_starting` with `retryAfterSeconds` until then; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 47 | **Interconnect-aware import: refuses honestly, with the reason and the setting** | ✗ | **✓ MEASURED + shipped 0.8.2** — on SOLIDWORKS Connected 2026 with `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic neutral-file import route at all: LoadFile4 returns nothing (`errors=1`) both with `Nothing` and with a real `GetImportFileData` object, LoadFile2 returns false, OpenDoc6 answers `swFileRequiresRepairError` (2097152) across four option values and `swInvalidFileTypeError` (1024) for docType 0. So `swx_import` makes ONE LoadFile4 attempt (a future edition that accepts it simply works) and then fails with `interconnect_blocks_import`, naming the exact setting — Tools > Options > System Options > Import > Enable 3D Interconnect — the measurement date, and the fact that **this bridge does not change user settings, not even temporarily**. No MCP server reads that toggle at all: they report a translator failure, which sends the reader hunting a missing translator that is not the problem | ✗ | ✗ | ✗ | ✗ | ✗ | | 46 | **3D Interconnect is understood, obeyed and reported** (the user's own setting decides how a neutral file comes in; the bridge reads it and never writes it) | ✗ | **✓ LIVE-MEASURED 2026-09-17, routed in 0.8.0** — `GetUserPreferenceToggle(691)` = TRUE on the install, so a STEP is not translated at all: `LoadFile4` executes and returns nothing with `errors=1`, and SOLIDWORKS instead OPENS the file with `OpenDoc6(path, swDocPART, …)` as a part holding a LINKED 3D Interconnect feature. `swx_import` reads the toggle, routes accordingly, and reports `interconnect` / `linked` / `sourcePath` so a caller knows the part is not self-contained. The preference is NEVER written — not even flip-and-restore, because a crash between the halves would leave the user's CAD configured differently than they left it — and `dissolve:true` is REFUSED until the link-breaking call is measured on this edition. Import-diagnostics auto-run (toggle 291) is also TRUE, so the modal dialog it raises is classified, reported as `stoppedByDialog`, and never answered. Toggle ids read from the installed `SolidWorks.Interop.swconst.dll` | ✗ | ✗ | ✗ | ✗ | ✗ | | 45 | **Closes its own documents without asking — and loses nothing doing it** (preserve-then-close: every open document written to a recovery folder and verified there BEFORE anything closes) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (0.7.7)** — `swx_shutdown {reason}` / `swx_close_all_safely {reason}` save every open document into `%LOCALAPPDATA%\adom-solidworks-bridge\recovery\<timestamp>\` (0.9.0: `Adom Bridge\solidworks-data\…`, inside the directory ab's installer owns; before that `…\bridges-cache\solidworks\recovery\…`, inside ab's install cache, which an update empties: row 49), verify it, close parents before children, then quit; no write opt-in, no per-document confirmation (Drew, 2026-09-11: *"I despise every time you ask for help closing"*). The authorization is to CLOSE, not to lose anything, so the consent question is replaced by checkable rules: the sweep REFUSES TO START when the recovery volume cannot hold the session it is about to write (`insufficient_headroom` with free/needed/shortfall, nothing written, nothing closed), re-checked before every save, with `headroomBytesBefore` and `estimatedWriteBytes` always reported — because a recovery file can be the RIGHT document CUT SHORT and no check on the file can tell (the marker is at offset 4, so every prefix carries it); the recovery file is verified STRUCTURALLY — it must be a recognised SOLIDWORKS container AND at least as big as the smallest real document of its KIND (part 24 KB, assembly 80 KB, drawing 256 KB — one floor for all three would have to be the lowest of them, letting an assembly stop at a third of its size), with the KIND carried from the COM read to the verdict and NEVER re-derived from the recovery file's name, because that name is chosen from the kind and so cannot check it; an unreadable document type falls back to the document's OWN PATH and then its title (evidence about the document, not names this sweep chose) and only then to UNKNOWN, which takes a neutral `.sldrecover` name and the strictest floor — and if SOLIDWORKS refuses to write that name the file is re-saved as `.SLDPRT` with the fallback RECORDED and the unknown-kind verification unchanged; `swx_recover_list` re-runs the verdict on every file it lists and reports `verified` with the reason — a file the sweep refused is listed (never deleted: those bytes are the only ones there are) but never called a rescue, never offered as openable, and its entry says the document was left OPEN — and gives every genuine rescue its own `howToOpen`, because SOLIDWORKS will not open a `.sldrecover` file until it is renamed, published as `recoverySignatureOk` + `recoveryBytes`, and it fails CLOSED (`exists()` passes a zero-byte file and any byte floor passes a truncated one; both are what a full disk leaves). The container marker was MEASURED on 166 real documents from three hardware repositories, because the signature every reference names for a SOLIDWORKS document (OLE compound file) appears in NONE of them — verifying that, as pass 23 specified, would have refused to close every document on a modern install; **FIRST AUTONOMOUS CLOSE, live 2026-09-17**: `swx_close_all_safely {userAuthorized, batch:1}` on a bridge-launched session took 4 documents (3 same-titled pathless import temps + a throwaway part) to `recovery\20260917-150646\` under unique names (25,834 / 26,031 / 25,773 / 26,047 bytes, `recoverySignatureOk:true`, `namingFallback:null`), closed each BY PATH, count 4 → 0, `done:true`, `stoppedBy:null`, headroom 1.59 TB — nobody was asked to close anything. A document that could not be written is left OPEN and named in `remaining`; the user's saved-then-edited file is copied to recovery under a new name and their original is asserted byte-for-byte unchanged; `SaveAs3` uses `swSaveAsOptions_Silent` ALONE so the document RE-POINTS at the recovery file and the close key is read from the document after the save; a dialog stops the sweep and is never answered, with the census taken before EVERY save and EVERY close (a save can raise a prompt of its own, which a per-batch census could not see until the closes had happened); and an UNREADABLE window census is its own outcome — saves proceed, nothing closes, `stoppedBy:"dialogCensusUnknown"`. Ordering is proven from a journal, not from the end state. Every rule is mutation-verified (disable it and exactly one test fails). **Ownership measured live 2026-09-30, both halves**: a pathless `Part2` preserved (25,959 bytes, verified, listed from the manifest) and Drew's saved `R_0603_1608Metric_1.step-1.sldprt` closed with its original untouched (same 32,311 bytes, same mtime). Since 0.9.0 such a document is RECORDED as `closedInPlace` with its original's size and mtime, and `swx_recover_list` says "closed at its own path, untouched" — or that it has changed since, or is gone — by measuring the file, not by assuming it | ✗ | ✗ | ✗ | ✗ | ✗ | -*(49 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; rows 43–45 from the import-leak, quit and safe-close work, and 46–49 from the live runs of 2026-09-17 and 2026-09-30 — the bar is "significantly more powerful than the MCP servers", so every robustness capability gets a row with its evidence level, not a slogan.)*+*(50 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; rows 43–45 from the import-leak, quit and safe-close work, and 46–49 from the live runs of 2026-09-17 and 2026-09-30 — 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/LAUNCH-RESILIENCE.md+47−3
@@ -996,9 +996,15 @@ ledger. (`deployN.py` side-loading a dev build on port 24695 beside the released one) is RETIRED: adom-bridge 2.1.163 terminates a second `swx-bridge.exe` within 30 s. Dev testing is a PRERELEASE install through ab, or a renamed exe reached with-`SWX_BRIDGE_URL` (the gates' escape hatch to a loopback `/command` URL). Which of-those ab tolerates is **not measured yet** — only the termination of a second-`swx-bridge.exe` has been seen. And the version ab hosts is whatever the+`SWX_BRIDGE_URL` (the gates' escape hatch to a loopback `/command` URL). **The+mechanism is now MEASURED (ab's own log, 2026-10-01):** `[ab 2026-09-30 17:02:12]+REAPED 1 instance(s) [(193004, 24695)] as duplicates; kept pid 150952 on the+assigned port 49861` (three times), and `[ab 2026-10-01 10:30:49] REAPED by ab:+applying the update to 0.9.2 - reaped so the next call respawns on the new code`.+ab reaps any bridge process bound to a bridge port that is not the instance it+tracks; a dev copy survives only by not looking like one (a different image name,+bound elsewhere) or by being installed THROUGH ab as the tracked instance — which+of those suits dev testing is still to be tried. And the version ab hosts is whatever the PUBLISHED manifest says, so **a release IS a deploy to every machine**: there is no staging step between `tools/release.sh --publish` and Drew's desktop running the new build.@@ -1036,3 +1042,41 @@ So, as of 0.6.8, two rules — both enforced mechanically rather than remembered The audit that produced the list found one unwired guard (`identity_matches_attribution`); `hwnd_still_owned`'s only caller is inside `#[cfg(windows)]` code, which is a production caller and counts.++## Live proof 3 — attached is not ready (2026-10-01, hosted 0.9.2)++ab auto-installed 0.9.2 at 10:30 local, straight from 0.8.9: the bridge cache was+re-created, and 0.9.2 created its data root `%LOCALAPPDATA%\adom-solidworks-bridge\`+at 10:43 — it now holds the ledger, the ownership registry and a recovery folder+(`Adom Bridge\solidworks-data` never existed on that machine). The 0.9.3 update+is therefore the FIRST measurement of "an ab update leaves our data root alone".++**The crash.** A warm launch: `Run Begin` 4.5 s, COM attach 29.7 s. Then+`swx_new_document` (keepUnsaved) at attach + 8 s, `swx_open` of a saved part at++9 s, `swx_close_all_safely` at +12 s — and SOLIDWORKS CRASHED INTERNALLY.+`%APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\SolidWorksPerformance.log`: `LOAD_ADDIN`+lines still being written, then `<DOC_OPEN> ".prtdot"`, `<SWEXH>` / `<UNHANDLED>` /+`</SWEXH>`, `<DOC_OPEN> ".sldprt"`, `<3DXINITERROR>0` (the 3DEXPERIENCE add-in+finished initialising AFTER the document commands), `<PROCMEM> CMD:DocSave`,+`<FT>1790869485`, `<RUNTIME>18`, `<SWERR_REPORT>`. No Windows Application Error+event; the journal stops at ActivateDoc2. The rescue HELD under the crash —+`Part1.SLDPRT`, 26,129 bytes, verified, in the new data root, nothing closed — but+the reply said `dialog_census_unknown` rather than "SOLIDWORKS is gone". A+CONTROL launch with no document commands: no `SWEXH`, and the log gained+`<3DXLOAD_TIME>24000</3DXLOAD_TIME>` when the platform load finished.++**What 0.9.3 does about it.**++| | |+|---|---|+| A READINESS stage after `attached` | `swx_start` says `stage:"ready"` only when SOLIDWORKS has finished starting, `stage:"starting"` (`stillStarting:true`) before; `swx_status` adds `solidworks.ready` and `solidworks.readiness` (what it is based on) |+| Two independent signals | COM `ISldWorks::StartupProcessCompleted` (added to `swx_com_eval`'s read allowlist; polled by the host's `startup_state`), and the performance log: a `<3DXLOAD_TIME>`/`<3DXINITERROR>` marker written AFTER the last `LOAD_ADDIN` line. Ready = every available signal agrees. With neither, a 60 s grace after first sight stands in and SAYS so (`graceAfterFirstSeen`); past five minutes it reports `readinessTimedOut` and stays `starting` — never promoted to ready by the clock |+| The gate | `swx_new_document`, `swx_open`, `swx_import`, `swx_save`, `swx_save_as`, `swx_run_script`, `swx_run_macro`, `swx_add_component`, `swx_close_doc`, `swx_close_all_documents`, `swx_close_all_safely`, `swx_close_import_temps`, `swx_reset_workspace`, `swx_shutdown` and `swx_stop` refuse `host_app_starting` with `retryAfterSeconds` until ready; nothing is sent to SOLIDWORKS. A wiring GUARD |+| SOLIDWORKS dies mid-sweep | `host_app_gone`: what was preserved (verified) and closed, and the session's crash markers from the performance log as `crashEvidence` — never `dialog_census_unknown` for a dead process |+| Lock files | `~$<name>` beside an open rescue is listed under `lockFiles`, never as a failed rescue |++**Not measured yet, and the next live run's first questions:** whether+`StartupProcessCompleted` turns true at the same moment the log's 3DEXPERIENCE+marker appears; whether `LOAD_ADDIN`-then-marker holds on every start (the log+format is undocumented — this is a heuristic read of one machine's log). The+launch gate now waits for READY before any probe of its own.
docs/QUIT-INVESTIGATION.md+55
@@ -202,6 +202,11 @@ are the PLM Services tray apps and are documented to stay (§2.6).  ### 0.5 The ledger has no `stop` event of any kind +> **CORRECTED 2026-10-01 (§5.15):** the session this section describes is now+> understood: a decline at zero documents happens on a session that has NEVER+> HELD a document, and stops happening once one has been opened and closed. The+> rule is about session HISTORY, not the current document count.+ `%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:@@ -257,6 +262,12 @@ themselves. Where a claim is folklore, it is labelled folklore.  ### 1.1 `ISldWorks::ExitApp` +> **CORRECTED 2026-10-01 (§5.15):** "ExitApp is not declined at zero documents"+> (09-17) and "ExitApp declined at zero documents" (09-08) are both true — of+> different sessions. MEASURED on one session (pid 20144): declined three times+> while it had never held a document; quit in 3.4 s once one scratch part had+> been created and closed in it.+ Official help (`…api/sldworksapi/…ISldWorks~ExitApp.html`, via snippet):  > "This method is not normally used with macros (*.swp) because it shuts down your@@ -1867,3 +1878,47 @@ a method. And the fact that decides how carefully a release is made: **the version ab hosts is whatever the published manifest says, so a release IS a deploy to every machine** that runs the bridge — there is no separate step at which a bad build can still be stopped.++### 5.15 The decline is about session HISTORY — measured 2026-10-01++One session, measured cleanly (pid 20144, bridge-launched, attributed, hosted+0.9.2): `swx_stop` rung (a) — UserControl=false; ExitApp; every COM reference+released; UserControl restored — was **DECLINED three times**, at ~50 s, ~2.5 min+and ~8 min old: 0 documents, no dialog, census readable, only the main frame+visible, the auto-recover folder empty (ledger: `solidworks_quit_rung+{"result":"declined","rung":"a"}` at 1790869846 and 1790869980). The session had+NEVER had a document open. Then, on the SAME session: `swx_new_document` (a scratch+part, identity path) → `swx_reset_workspace` closed it → `swx_stop` rung (a) →+`{"result":"quit","processGone":true,"elapsedSec":3.4}`.++That reconciles September: 09-08's "ExitApp declined at zero documents" was a+never-had-a-document session; 09-17's "ExitApp is not declined at zero documents"+was measured on sessions that had held documents. Startup timing is NOT the cause+— the declines spanned eight minutes, long after any start had finished — though+0.9.3 gates the quit on readiness anyway (see LAUNCH-RESILIENCE "Live proof 3").++**What 0.9.3 does (`src/session_history.rs`, `prime_session` in `src/verbs.rs`):**++* The bridge records per session — (pid, creation time), in+  `session-history.json` in the data root — whether it has ever held a document,+  whenever a verb shows one open. `everHadDocument` is `false` only for a session+  the bridge launched and watched from its start, and UNKNOWN for one it attached+  to.+* Rung (a), only under `userAuthorized` and only on an EMPTY session with no+  dialog: if ExitApp is declined and `everHadDocument` is false or unknown, the+  ladder PRIMES the session — one bridge-owned scratch part created and closed+  through the ordinary owned-document path (default posture, registered, ledgered+  `quit_prime`) — and asks ExitApp once more. Both attempts are in `rungsTried`,+  the second `primed:true`. Never with a document open: the emptiness is re-read+  right before priming, and a document that appeared in between stops it. The+  simulator models a never-had-a-document session (`exitedIfEverHadDocument`),+  and both mutations — priming removed, priming with a document open — are caught+  by `tests/quit.rs`.+* The quit gate's "swx_status no longer reports a running SOLIDWORKS" claim was+  VACUOUS: it passed whenever the quit had been declined, and printed PASS twice+  while `running` was true. It now requires `running:false`, and+  `tools/quit-gate.py --self-test` (run by the suite) pins it with canned replies.++The coordinator is still investigating whether a session relaunched after a crash+holds document-recovery state; this section does not claim history is the only+cause of a decline — only that it is one, measured.
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.2",+  "version": "0.9.3",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
page.json+1−7
@@ -22,11 +22,5 @@       "label": "Change a dimension",       "prompt": "Set the extrusion depth on my SolidWorks part to 25mm"     }-  ],-  "hero": {-    "type": "billboard",-    "auto": true,-    "headline": "solidworks-bridge",-    "subhead": "On the Adom Wiki."-  }+  ] }\ No newline at end of file
references/closing-and-recovery.md+12
@@ -99,6 +99,18 @@ Read this before closing any document, quitting SOLIDWORKS, draining leaked impo   permissions, a quota, a file where the folder must go; the reply names the   cause once, before any document was saved). Say what the reply says; do not   retry until the cause is fixed.+- **`host_app_gone` means SOLIDWORKS died during the sweep** — not a dialog, not a+  disk problem. The reply says how many documents were preserved (each verified)+  and closed before it went, and quotes the crash markers SOLIDWORKS wrote in its+  performance log (`crashEvidence`). Tell the user what was preserved+  (`swx_recover_list`), and wait for `solidworks.ready` after they restart it.+  `lockFiles` in a listed sweep are SOLIDWORKS' `~$` lock files: not rescues.+- **A quit declined on an empty session may be PRIMED.** MEASURED 2026-10-01: a+  session that has never held a document can decline ExitApp until one has been+  opened and closed. Under `userAuthorized`, on an empty session with no dialog,+  `swx_stop` creates and closes one bridge-owned scratch part and asks again; the+  second attempt in `rungsTried` says `primed:true`. Nothing is ever primed while+  a document is open. - **`swx_shutdown` is idempotent.** SOLIDWORKS not running answers `success:true,   alreadyStopped:true`. It is the last thing you do, and the last thing should be   safe to repeat.
references/launch-and-sign-in.md+11
@@ -66,6 +66,17 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE  ## Is it ready? +- **`attached` is not `ready`.** MEASURED 2026-10-01: document commands sent 8 s+  after COM attach crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still+  initialising. `swx_start` now reports `stage:"starting"` (`stillStarting:true`)+  until SOLIDWORKS has finished starting, then `stage:"ready"`; `swx_status` says+  `solidworks.ready` and what it is based on (`StartupProcessCompleted`, the+  performance log, or a grace it names). Every document verb and the quit refuse+  `host_app_starting` with `retryAfterSeconds` until then — wait that long and+  call again; never try to get around it. `readinessTimedOut:true` means it has+  been over five minutes: look at the user's screen (a dialog, a sign-in) rather+  than forcing anything.+ - `bridge_readiness` (ab-level, never spawns anything) answers "is SOLIDWORKS   installed / is the bridge warm" — use it for what-do-I-have questions. - `swx_readiness` is the bridge's own read-only probe; `swx_status` is the
src/comhost/com.rs+22
@@ -2625,6 +2625,7 @@ impl Backend for ComBackend {             "show" => self.cmd_show(args),             "check_recovery" => self.cmd_check_recovery(),             "app_state" => self.cmd_app_state(),+            "startup_state" => self.cmd_startup_state(),             "cloud_status" => self.cmd_cloud_status(),              other => Err(HostError::msg(format!("unknown command: {other}"))),@@ -4231,6 +4232,27 @@ impl ComBackend {      /// `swx_get_app_state` — the Fusion-shaped snapshot, from COM only (the     /// server adds the window/dialog half through ab).+    /// HAS SOLIDWORKS FINISHED STARTING? `ISldWorks::StartupProcessCompleted`, a+    /// read. Cheap on purpose — no document enumeration — because the readiness+    /// gate asks it before every document command until the answer is yes.+    /// MEASURED 2026-10-01: COM attach is not readiness; document commands sent+    /// 8 s after attach crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still+    /// initialising. Whether this property turns true at the right moment is the+    /// next measurement; `null` means SOLIDWORKS would not answer it.+    fn cmd_startup_state(&mut self) -> Result<Value, HostError> {+        let sw = match self.app(false, true) {+            Ok(sw) => sw,+            Err(_) => {+                return Ok(json!({"running": false, "startupCompleted": Value::Null, "processId": Value::Null}))+            }+        };+        Ok(json!({+            "running": true,+            "startupCompleted": sw.flag("StartupProcessCompleted", &[]),+            "processId": sw.int("GetProcessID", &[]),+        }))+    }+     fn cmd_app_state(&mut self) -> Result<Value, HostError> {         let sw = match self.app(false, true) {             Ok(sw) => sw,
src/comhost/expr.rs+5
@@ -187,6 +187,11 @@ pub const READ_MEMBERS: &[&str] = &[     "referencedconfiguration",     // rebuild / health reads     "needsrebuild", "needsrebuild2",+    // startup: has SOLIDWORKS finished starting (add-ins included)? A read, and+    // the signal the readiness gate polls — measured on 2026-10-01 to be needed:+    // document commands sent 8 s after COM attach crashed SOLIDWORKS while its+    // 3DEXPERIENCE add-in was still initialising.+    "startupprocesscompleted", ];  /// Members explicitly called out as REFUSED, so the error can say why rather
src/comhost/host.rs+5
@@ -100,6 +100,9 @@ impl HostError { pub const READ_ONLY_COMMANDS: &[&str] = &[     "ping",     "status",+    // Has SOLIDWORKS finished STARTING? Polled by the readiness gate before any+    // document command (see src/readiness.rs).+    "startup_state",     "start",     "docs",     // Read-only ONLY: `open {readOnly:false}` is treated as mutating — see@@ -822,6 +825,8 @@ pub const COMMANDS: &[&str] = &[     "eval",     // v0.3 — the Fusion-parity surface.     "new_document",+    // 0.9.3 — has SOLIDWORKS finished starting? (the readiness gate)+    "startup_state",     "activate_document",     "close_all_documents",     "document_info",
src/comhost/mock.rs+57
@@ -862,6 +862,12 @@ impl MockBackend {         // correctly refuses to start a second copy, so the launch suite went         // red. The census must describe the machine, not the fact that someone         // asked about it.+        // A SESSION'S HISTORY, kept the way the real one keeps it: once a document+        // has been open in this session, ExitApp behaves differently (measured+        // 2026-10-01 — see the `exitedIfEverHadDocument` quit-script outcome).+        if self.running && !self.docs.is_empty() {+            let _ = std::fs::write(dir.join("mock-session-ever-had-document"), self.session().to_string());+        }         if self.running {             let _ = std::fs::write(&f, self.session().to_string());         } else {@@ -874,6 +880,9 @@ impl MockBackend {     /// Part1, which is the whole reason document ownership is scoped to a     /// session.     fn finish_exit(&mut self) {+        if let Some(d) = fake_dir() {+            let _ = std::fs::remove_file(d.join("mock-session-ever-had-document"));+        }         self.running = false;         self.docs.clear();         self.active = None;@@ -1337,7 +1346,31 @@ impl MockBackend {                     _ if fake_dir().is_none() => ("exited".to_string(), 0),                     _ => ("declined".to_string(), 0),                 };+                // MEASURED 2026-10-01 (pid 20144): rung (a) was DECLINED three times+                // on a bridge-launched session that had NEVER had a document open,+                // and quit in 3.4 s on the same session once one scratch part had+                // been created and closed. `exitedIfEverHadDocument` models that:+                // declined until this session has held a document, then exited.+                let outcome = if outcome == "exitedIfEverHadDocument" {+                    let had = fake_dir()+                        .and_then(|d| std::fs::read_to_string(d.join("mock-session-ever-had-document")).ok())+                        .map(|t| t.trim() == self.session().to_string())+                        .unwrap_or(false);+                    if had { "exited".to_string() } else { "declined".to_string() }+                } else {+                    outcome+                };                 let mut detail = vec![format!("simulated rung '{rung}': {outcome}")];+                // THE USER OPENS A DOCUMENT just as the rung is declined — the+                // moment priming must notice, because priming is never done to a+                // session somebody is working in.+                if outcome == "declined" && fault("mock-doc-appears-after-decline") {+                    let path = "C:\\Users\\drew\\work\\opened-meanwhile.SLDPRT".to_string();+                    if self.find_by_name(&path).is_none() {+                        self.docs.push(MockDoc::opened(path, "part", "Default".into()));+                        self.publish_session();+                    }+                }                 match outcome.as_str() {                     "exited" | "quit" => {                         // ONE source of truth: the census (FakeOs) reads this@@ -1724,6 +1757,11 @@ impl MockBackend {                     ));                 }                 journal("preserved", &out);+                // SOLIDWORKS DIES right after this save — the 2026-10-01 crash,+                // simulated: the save landed, and nothing answers after it.+                if fault("mock-crash-after-first-save") {+                    self.running = false;+                }                 // THE DOCUMENT RE-POINTS at the recovery file, exactly as                 // SaveAs3 with Silent alone does: the close key is read from                 // the document AFTER the save, and it must be this path.@@ -3016,6 +3054,25 @@ impl MockBackend {              // NOTE ON COST: `app_state` reads the COUNT first and enumerates only             // under the cap — so it is charged inside that branch, below.+            // HAS SOLIDWORKS FINISHED STARTING? The simulator says yes unless told+            // otherwise: `mock-startup-incomplete` (still starting) or+            // `mock-startup-unreadable` (SOLIDWORKS will not say).+            "startup_state" => {+                self.publish_session();+                Ok(json!({+                    "running": self.running,+                    "startupCompleted": if !self.running || fault("mock-startup-unreadable") {+                        Value::Null+                    } else {+                        json!(!fault("mock-startup-incomplete"))+                    },+                    // The session, NOT `mock_com_pid()`: that is a scripted+                    // per-read sequence the quit tests count read by read, and the+                    // readiness gate must not consume a read of it.+                    "processId": if self.running { json!(self.session()) } else { Value::Null },+                }))+            }+             "app_state" => {                 self.publish_session();                 // A com host that cannot answer. The point of the switch is that
src/ledger.rs+96−8
@@ -20,9 +20,13 @@ use serde_json::{json, Map, Value}; use std::sync::Mutex; -/// Ledger lines kept on disk. Older lines are dropped on the next append once-/// the file exceeds this, so the diary cannot outgrow the scratch budget.-const LEDGER_MAX_LINES: usize = 2_000;+/// Ledger lines the live file is trimmed BACK TO. Trimming happens only once the+/// file passes TWICE this (`LEDGER_HIGH_WATER`), so a merge of two capped+/// ledgers (adversary pass 41) is not cut back on the start that made it — and+/// trimming never deletes: the lines it removes go to `ledger-archive-<stamp>.jsonl`+/// in the data root, and a `ledger_archived` line says how many and where.+pub const LEDGER_MAX_LINES: usize = 2_000;+pub const LEDGER_HIGH_WATER: usize = 2 * LEDGER_MAX_LINES; /// Samples kept per verb in the timing store — enough for a stable p90, small /// enough that the file stays a few KB. const TIMING_SAMPLES: usize = 50;@@ -56,7 +60,6 @@ pub fn record(action: &str, detail: Value) { /// without mutating the process environment (which would race the other tests /// sharing this process). pub fn record_in(root: &std::path::Path, action: &str, detail: Value) {-    let entry = json!({"at": now_epoch(), "action": action, "detail": detail});     let _guard = LEDGER_LOCK.lock();     let path = ledger_path(root);     if let Some(parent) = path.parent() {@@ -65,11 +68,44 @@ pub fn record_in(root: &std::path::Path, action: &str, detail: Value) {     let mut lines: Vec<String> = std::fs::read_to_string(&path)         .map(|s| s.lines().map(|l| l.to_string()).collect())         .unwrap_or_default();-    lines.push(entry.to_string());-    if lines.len() > LEDGER_MAX_LINES {-        let drop = lines.len() - LEDGER_MAX_LINES;-        lines.drain(0..drop);+    // EVERY LINE CARRIES A SEQUENCE NUMBER (adversary pass 41). `at` is seconds+    // and some details say only {title, by}, so two real events could be+    // byte-identical — and a merge could not tell them from one event copied.+    // With `seq`, identity never depends on content.+    let mut seq = lines+        .iter()+        .filter_map(|l| serde_json::from_str::<Value>(l).ok()?.get("seq")?.as_u64())+        .max()+        .unwrap_or(0);+    let mut next = || {+        seq += 1;+        seq+    };+    // NEVER A SILENT CUT. Past the high-water mark the oldest lines go to an+    // ARCHIVE file first, and only if that write succeeds are they removed here,+    // with a line saying how many and where. A failed archive keeps them all.+    if lines.len() + 1 > LEDGER_HIGH_WATER {+        let drop = lines.len() + 2 - LEDGER_MAX_LINES;+        let archived: Vec<String> = lines[..drop.min(lines.len())].to_vec();+        let stamp = crate::scratch::stamp_now();+        let mut name = format!("ledger-archive-{stamp}.jsonl");+        let mut n = 2;+        while root.join(&name).exists() {+            name = format!("ledger-archive-{stamp}-{n}.jsonl");+            n += 1;+        }+        if crate::launch::write_atomic(&root.join(&name), &(archived.join("\n") + "\n")).is_ok() {+            lines.drain(0..archived.len());+            lines.push(+                json!({"at": now_epoch(), "seq": next(), "action": "ledger_archived",+                       "detail": {"lines": archived.len(), "archive": name,+                                  "why": "the live ledger passed its high-water mark; the oldest lines were moved, not deleted"}})+                .to_string(),+            );+        }     }+    let entry = json!({"at": now_epoch(), "seq": next(), "action": action, "detail": detail});+    lines.push(entry.to_string());     // ATOMIC. The ledger is the one artefact that outlives the bridge — it is     // the record of what this bridge did on somebody's machine while they were     // not watching — and a plain `write` truncates before it fills. A kill in@@ -183,6 +219,58 @@ pub fn timed<T>(verb: &str, f: impl FnOnce() -> T) -> T { mod tests {     use super::*; +    fn temp_root(tag: &str) -> std::path::PathBuf {+        let d = std::env::temp_dir().join(format!("swx-ledger-{tag}-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(&d).unwrap();+        d+    }++    #[test]+    fn every_line_carries_a_sequence_number_that_only_goes_up() {+        let root = temp_root("seq");+        // A pre-0.9.3 ledger: no `seq` on its lines.+        std::fs::write(ledger_path(&root), "{\"at\":1,\"action\":\"old\",\"detail\":{}}\n").unwrap();+        for _ in 0..3 {+            record_in(&root, "document_closed", json!({"title": "smoke.SLDPRT", "by": "reset_workspace"}));+        }+        let seqs: Vec<u64> = std::fs::read_to_string(ledger_path(&root))+            .unwrap()+            .lines()+            .filter_map(|l| serde_json::from_str::<Value>(l).ok()?.get("seq")?.as_u64())+            .collect();+        assert_eq!(seqs, vec![1, 2, 3], "three identical events are three distinct lines");+        let _ = std::fs::remove_dir_all(&root);+    }++    #[test]+    fn the_cap_archives_instead_of_cutting() {+        let root = temp_root("archive");+        let lines: Vec<String> = (0..LEDGER_HIGH_WATER)+            .map(|i| json!({"at": i, "seq": i + 1, "action": "parameter_set", "detail": {"n": i}}).to_string())+            .collect();+        std::fs::write(ledger_path(&root), lines.join("\n") + "\n").unwrap();+        record_in(&root, "after", json!({}));+        let live: Vec<Value> = std::fs::read_to_string(ledger_path(&root))+            .unwrap()+            .lines()+            .filter_map(|l| serde_json::from_str(l).ok())+            .collect();+        let note = live.iter().find(|v| v["action"] == "ledger_archived").expect("a line says lines were moved");+        let archive = root.join(note["detail"]["archive"].as_str().unwrap());+        let archived: Vec<Value> = std::fs::read_to_string(&archive)+            .expect("the archive exists")+            .lines()+            .filter_map(|l| serde_json::from_str(l).ok())+            .collect();+        assert_eq!(note["detail"]["lines"].as_u64(), Some(archived.len() as u64));+        assert_eq!(archived.len() + live.len(), LEDGER_HIGH_WATER + 2, "nothing was lost: every line is live or archived");+        assert_eq!(archived[0]["detail"]["n"], 0, "the OLDEST lines are the ones archived");+        assert!(live.len() <= LEDGER_MAX_LINES);+        assert_eq!(live.last().unwrap()["action"], "after");+        let _ = std::fs::remove_dir_all(&root);+    }+     #[test]     fn percentiles_are_observed_samples_not_interpolations() {         let v = vec![1.0, 2.0, 3.0, 4.0, 10.0];
src/lib.rs+3
@@ -37,12 +37,15 @@ pub mod launch; pub mod launchos; pub mod ledger; pub mod owned;+pub mod perflog; pub mod quit;+pub mod readiness; pub mod disk; pub mod scratch; pub mod sha256; pub mod script; pub mod server;+pub mod session_history; pub mod verbs; pub mod verbs3; pub mod writes;
src/perflog.rsadded+105
@@ -0,0 +1,105 @@+//! SOLIDWORKS' own performance log — a second witness that needs no COM.+//!+//! `%APPDATA%\SOLIDWORKS\SOLIDWORKS <year>\SolidWorksPerformance.log` is written+//! by SOLIDWORKS as it runs. MEASURED 2026-10-01 on Drew's machine (hosted 0.9.2):+//!+//!   * a start writes `LOAD_ADDIN` lines while add-ins load, and the+//!     3DEXPERIENCE add-in reports when it has finished — `<3DXLOAD_TIME>24000`+//!     on a clean start, `<3DXINITERROR>` on the start that crashed;+//!   * the crash: document commands sent 8 s after COM attach ran while+//!     `LOAD_ADDIN` lines were still being written; the log then shows+//!     `<SWEXH>` / `<UNHANDLED>` / `</SWEXH>`, the 3DEXPERIENCE marker AFTER the+//!     document commands, `<PROCMEM> CMD:DocSave`, `<RUNTIME>`, `<SWERR_REPORT>`;+//!   * a control launch with no document commands: no `SWEXH`, and the log gains+//!     `<3DXLOAD_TIME>` when the platform load finishes.+//!+//! So this module answers two questions from the file alone: has the current+//! start finished (a 3DEXPERIENCE marker written AFTER the last `LOAD_ADDIN`+//! line), and what did SOLIDWORKS say on its way down (the crash markers in the+//! tail). The first is a HEURISTIC over a format nobody documents — the+//! readiness gate pairs it with COM's `StartupProcessCompleted` and says which+//! signals it used. `SWX_PERF_LOG` overrides the path (an empty value means "no+//! log"), which is how the suite supplies one.++use std::path::PathBuf;++/// How much of the end of the log is read. The markers that matter are recent,+/// and the file grows for the life of the install.+const TAIL_BYTES: u64 = 2 * 1024 * 1024;++const LOAD_MARKERS: &[&str] = &["<3DXLOAD_TIME>", "<3DXINITERROR>"];+const CRASH_MARKERS: &[&str] = &["<SWEXH>", "<UNHANDLED>", "<SWERR_REPORT>", "<RUNTIME>"];++/// The log, if there is one. Of several `SOLIDWORKS <year>` folders, the one+/// whose log was written most recently.+pub fn path() -> Option<PathBuf> {+    if let Ok(p) = std::env::var("SWX_PERF_LOG") {+        return if p.trim().is_empty() { None } else { Some(PathBuf::from(p)) };+    }+    let appdata = std::env::var("APPDATA").ok().filter(|a| !a.trim().is_empty())?;+    let base = PathBuf::from(appdata).join("SOLIDWORKS");+    let mut best: Option<(std::time::SystemTime, PathBuf)> = None;+    for e in std::fs::read_dir(&base).ok()?.flatten() {+        let log = e.path().join("SolidWorksPerformance.log");+        if let Ok(m) = std::fs::metadata(&log).and_then(|m| m.modified()) {+            if best.as_ref().map(|(t, _)| m > *t).unwrap_or(true) {+                best = Some((m, log));+            }+        }+    }+    best.map(|(_, p)| p)+}++fn tail() -> Option<String> {+    use std::io::{Read, Seek, SeekFrom};+    let mut f = std::fs::File::open(path()?).ok()?;+    let len = f.metadata().ok()?.len();+    let start = len.saturating_sub(TAIL_BYTES);+    f.seek(SeekFrom::Start(start)).ok()?;+    let mut buf = Vec::new();+    f.read_to_end(&mut buf).ok()?;+    Some(String::from_utf8_lossy(&buf).to_string())+}++/// HAS THE CURRENT START FINISHED, by the log? `None` when the log cannot say:+/// there is no log, or it has never held a 3DEXPERIENCE marker (an edition that+/// does not write one, or a log too new to tell). `Some(true)` when a marker+/// sits after the last `LOAD_ADDIN` line — the add-ins of the latest start have+/// loaded and the platform has reported. `Some(false)` when the latest+/// `LOAD_ADDIN` line is newer than every marker: a start is still loading.+pub fn startup_complete() -> (Option<bool>, String) {+    let Some(t) = tail() else {+        return (None, "no SOLIDWORKS performance log to read".into());+    };+    let last_marker = LOAD_MARKERS.iter().filter_map(|m| t.rfind(m).map(|i| (i, *m))).max();+    let last_addin = t.rfind("LOAD_ADDIN");+    match (last_marker, last_addin) {+        (None, _) => (+            None,+            "the performance log holds no 3DEXPERIENCE load marker to judge by".into(),+        ),+        (Some((m, name)), Some(a)) if a > m => (+            Some(false),+            format!("add-ins are still loading: the last LOAD_ADDIN line is newer than the last {name}"),+        ),+        (Some((_, name)), _) => (Some(true), format!("{name} written after the last LOAD_ADDIN line")),+    }+}++/// WHAT SOLIDWORKS WROTE ON ITS WAY DOWN: the last lines of the tail that carry+/// a crash marker, verbatim, newest last. Empty when there are none — which is+/// not proof of anything.+pub fn crash_evidence() -> Vec<String> {+    let Some(t) = tail() else { return Vec::new() };+    let lines: Vec<&str> = t.lines().collect();+    // Only what was written after the last start's add-ins began loading: an+    // older session's crash is not this one's.+    let from = lines.iter().rposition(|l| l.contains("LOAD_ADDIN")).unwrap_or(0);+    let hits: Vec<String> = lines[from..]+        .iter()+        .filter(|l| CRASH_MARKERS.iter().any(|m| l.contains(m)))+        .map(|l| l.trim().to_string())+        .collect();+    let keep = hits.len().saturating_sub(20);+    hits[keep..].to_vec()+}
src/quit.rs+4
@@ -344,6 +344,9 @@ pub struct RungTry {     pub census_readable: bool,     pub detail: Option<String>,     pub host_error: Option<String>,+    /// This attempt followed PRIMING a never-had-a-document session (one+    /// bridge-owned scratch part created and closed) — measured 2026-10-01.+    pub primed: bool, }  impl RungTry {@@ -366,6 +369,7 @@ impl RungTry {             "censusReadable": self.census_readable,             "detail": self.detail,             "hostError": self.host_error,+            "primed": self.primed,         })     } }
src/readiness.rsadded+130
@@ -0,0 +1,130 @@+//! IS SOLIDWORKS READY? — not "is it attached".+//!+//! MEASURED 2026-10-01 on Drew's machine: a warm launch reached `Run Begin` in+//! 4.5 s and COM attach in 29.7 s. `swx_new_document` 8 s after attach,+//! `swx_open` at 9 s and `swx_close_all_safely` at 12 s CRASHED SOLIDWORKS: its+//! performance log shows add-ins still loading, an unhandled exception during+//! the document commands, and the 3DEXPERIENCE add-in finishing its+//! initialisation only AFTER them. A control launch with no document commands+//! crashed nothing and logged `<3DXLOAD_TIME>` when the platform load finished.+//! So `attached` is not `ready`, and this module is the gate between them.+//!+//! Two signals, each independent of the other:+//!   * COM: `ISldWorks::StartupProcessCompleted` (host command `startup_state`);+//!   * the performance log: a 3DEXPERIENCE load marker written after the last+//!     `LOAD_ADDIN` line (`perflog::startup_complete`) — file-based, no COM.+//! READY = every available signal says so. When NEITHER can be read, a grace of+//! `GRACE_SECS` after the bridge first saw this session stands in, and the+//! answer says so (`basedOn: ["graceAfterFirstSeen"]`) — never silently. A start+//! that does not become ready within `TIMEOUT_SECS` stays `starting`, with+//! `readinessTimedOut:true`: it is never promoted to ready by the clock.+//! Once a session (by process id) is ready it stays ready.++use serde_json::{json, Value};+use std::collections::HashMap;+use std::sync::Mutex;+use std::time::Instant;++pub const GRACE_SECS: u64 = 60;+pub const TIMEOUT_SECS: u64 = 300;+pub const RETRY_AFTER_SECS: u64 = 5;++struct Seen {+    first: Instant,+    ready: bool,+}++static SESSIONS: Mutex<Option<HashMap<i64, Seen>>> = Mutex::new(None);++/// The readiness of the running session, with what it is based on. `running`+/// false (or a host that cannot answer) means there is no session to judge, and+/// `ready` is null.+pub fn assess(ctx: &crate::verbs::Ctx) -> Value {+    let state = match ctx.call("startup_state", json!({}), 20) {+        Ok(s) => s,+        Err(e) => {+            return json!({"running": Value::Null, "ready": Value::Null,+                          "why": format!("the com host could not say: {}", e.message)})+        }+    };+    if state.get("running").and_then(|v| v.as_bool()) != Some(true) {+        return json!({"running": false, "ready": Value::Null, "why": "SOLIDWORKS is not running"});+    }+    let pid = state.get("processId").and_then(|v| v.as_i64());+    let com = state.get("startupCompleted").and_then(|v| v.as_bool());+    let (log, log_why) = crate::perflog::startup_complete();+    let mut guard = SESSIONS.lock().unwrap_or_else(|e| e.into_inner());+    let map = guard.get_or_insert_with(HashMap::new);+    let key = pid.unwrap_or(-1);+    let seen = map.entry(key).or_insert(Seen { first: Instant::now(), ready: false });+    let age = seen.first.elapsed().as_secs();+    let mut based_on: Vec<&str> = Vec::new();+    let ready = if seen.ready && pid.is_some() {+        based_on.push("alreadyReadyThisSession");+        true+    } else {+        let mut verdicts: Vec<bool> = Vec::new();+        if let Some(c) = com {+            based_on.push("StartupProcessCompleted");+            verdicts.push(c);+        }+        if let Some(l) = log {+            based_on.push("performanceLog");+            verdicts.push(l);+        }+        if verdicts.is_empty() {+            based_on.push("graceAfterFirstSeen");+            age >= GRACE_SECS+        } else {+            verdicts.iter().all(|v| *v)+        }+    };+    if ready && pid.is_some() {+        seen.ready = true;+    }+    json!({+        "running": true,+        "ready": ready,+        "processId": pid,+        "basedOn": based_on,+        "startupProcessCompleted": com,+        "performanceLog": {"complete": log, "why": log_why},+        "secondsSinceFirstSeen": age,+        "readinessTimedOut": !ready && age >= TIMEOUT_SECS,+    })+}++/// THE GATE: no document-creating, -opening, -saving or -closing COM call — and+/// no quit — until SOLIDWORKS has finished starting. `None` lets the verb run:+/// SOLIDWORKS is ready, or not running at all (the verb then answers that for+/// itself), or the host could not say (the verb's own call meets the same+/// failure and reports it). `Some` is the refusal.+pub fn refuse_until_ready(ctx: &crate::verbs::Ctx, verb: &str) -> Option<Value> {+    let r = assess(ctx);+    if r.get("ready").and_then(|v| v.as_bool()) != Some(false) {+        return None;+    }+    let timed_out = r.get("readinessTimedOut").and_then(|v| v.as_bool()) == Some(true);+    let mut out = crate::verbs::err_code(+        "host_app_starting",+        format!(+            "{verb} refused: SOLIDWORKS is attached but has not finished starting (add-ins, the \+             3DEXPERIENCE platform load). Document commands sent in that window crashed \+             SOLIDWORKS on 2026-10-01."+        ),+        if timed_out {+            "SOLIDWORKS has not reported a finished start for over five minutes. It is still \+             treated as starting — the clock never promotes it to ready. Look at swx_status's \+             `solidworks.readiness` and at the user's screen (a dialog, a sign-in); do not \+             force document commands through."+        } else {+            "Wait `retryAfterSeconds`, then call again — or poll swx_status until \+             `solidworks.ready` is true. Nothing was sent to SOLIDWORKS."+        },+    );+    if let Value::Object(m) = &mut out {+        m.insert("retryAfterSeconds".into(), json!(RETRY_AFTER_SECS));+        m.insert("readiness".into(), r);+    }+    Some(out)+}
src/scratch.rs+30−12
@@ -54,8 +54,8 @@ //! root keeps the name" handed the live ledger the STALE copy on exactly Drew's //! timeline (the cache re-created by 0.8.9 after 0.9.0 had copied it and kept //! appending), and "newest mtime wins" fails the mirror case. So the live-//! `ledger.jsonl` becomes the union of both — each exact line once, ordered by-//! `at` — and the live `owned-docs.json` the union of both claim lists (restore+//! `ledger.jsonl` becomes the union of both — a multiset: each distinct line as+//! many times as the version that holds it most, ordered by `at` — and the live `owned-docs.json` the union of both claim lists (restore //! re-validates every claim against the live session). Both versions are kept //! beside the merged file, and the merge is recorded in the migration record and //! the ledger. Any other file keeps the beside rule.@@ -436,15 +436,20 @@ pub fn merge_kind(rel: &Path) -> Option<Merge> {     } } -/// THE LEDGER, MERGED: every line of both, each exact line once, ordered by `at`.-/// The sort is stable, and a line with no readable `at` takes the `at` of the-/// line before it in its own file, so it stays beside the entries it was written-/// among. Returns the merged text and its line count.+/// THE LEDGER, MERGED — AS A MULTISET (adversary pass 41). A line in BOTH files+/// is one event copied, so it appears once; but two identical lines WITHIN one+/// file are two events (a ledger line before 0.9.3 has no sequence number, `at`+/// is seconds, and `document_closed` from swx_reset_workspace says only+/// {title, by}). So each distinct line appears as many times as the file that+/// holds it MORE times — max(count in live, count in incoming) — never fewer.+/// The result is stable-sorted by `at`; a line with no readable `at` takes the+/// `at` of the line before it in its own file, so it stays beside the entries it+/// was written among. Returns the merged text and its line count. pub fn merge_ledger_lines(live: &str, incoming: &str) -> (String, usize) {-    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();-    let mut lines: Vec<(u64, String)> = Vec::new();-    for text in [live, incoming] {+    use std::collections::HashMap;+    fn parse(text: &str) -> Vec<(u64, String)> {         let mut last_at = 0u64;+        let mut out = Vec::new();         for line in text.lines() {             let line = line.trim_end_matches('\r');             if line.trim().is_empty() {@@ -455,9 +460,22 @@ pub fn merge_ledger_lines(live: &str, incoming: &str) -> (String, usize) {                 .and_then(|v| v.get("at").and_then(|a| a.as_u64()))                 .unwrap_or(last_at);             last_at = at;-            if seen.insert(line.to_string()) {-                lines.push((at, line.to_string()));-            }+            out.push((at, line.to_string()));+        }+        out+    }+    let mut lines: Vec<(u64, String)> = Vec::new();+    let mut live_counts: HashMap<String, usize> = HashMap::new();+    for (at, l) in parse(live) {+        *live_counts.entry(l.clone()).or_insert(0) += 1;+        lines.push((at, l));+    }+    let mut seen_incoming: HashMap<String, usize> = HashMap::new();+    for (at, l) in parse(incoming) {+        let c = seen_incoming.entry(l.clone()).or_insert(0);+        *c += 1;+        if *c > live_counts.get(&l).copied().unwrap_or(0) {+            lines.push((at, l));         }     }     lines.sort_by_key(|(at, _)| *at);
src/session_history.rsadded+101
@@ -0,0 +1,101 @@+//! HAS THIS SOLIDWORKS SESSION EVER HELD A DOCUMENT?+//!+//! MEASURED 2026-10-01 on Drew's machine (pid 20144, bridge-launched, attributed,+//! hosted 0.9.2): `swx_stop` rung (a) — UserControl=false, ExitApp, every COM+//! reference released — was DECLINED three times, at ~50 s, ~2.5 min and ~8 min+//! old: 0 documents, no dialog, census readable, only the main frame visible, an+//! empty auto-recover folder. The session had NEVER had a document open. Then, on+//! the SAME session: one scratch part created (`swx_new_document`), closed+//! (`swx_reset_workspace`), and rung (a) quit in 3.4 s. That reconciles the two+//! September observations — 09-08's "ExitApp declined at zero documents" was a+//! never-had-a-document session, 09-17's "not declined at zero documents" were+//! sessions that had held documents. So the rule is about session HISTORY, not+//! the current document count, and the quit ladder needs to know it.+//!+//! Recorded per session — (pid, creation time) — in `session-history.json` in the+//! data root, whenever a verb shows a document open in it. `None` (UNKNOWN) when+//! the bridge did not watch the session from its start: it attached to one it+//! did not launch, so a document may have come and gone unseen.++use serde_json::{json, Value};++fn path() -> std::path::PathBuf {+    crate::scratch::root().join("session-history.json")+}++fn key(pid: u32, created: Option<u64>) -> String {+    format!("{pid}:{}", created.map(|c| c.to_string()).unwrap_or_else(|| "?".into()))+}++fn load() -> serde_json::Map<String, Value> {+    std::fs::read_to_string(path())+        .ok()+        .and_then(|t| serde_json::from_str::<Value>(&t).ok())+        .and_then(|v| v.as_object().cloned())+        .unwrap_or_default()+}++/// Record that this session has held a document. Keeps the twenty most recent+/// sessions; older ones cannot be running any more.+pub fn mark(pid: u32, created: Option<u64>) {+    let mut m = load();+    let k = key(pid, created);+    if m.get(&k).and_then(|v| v.get("everHadDocument")).and_then(|v| v.as_bool()) == Some(true) {+        return;+    }+    m.insert(k, json!({"everHadDocument": true, "atMs": crate::launchos::epoch_ms() as u64}));+    while m.len() > 20 {+        let oldest = m+            .iter()+            .min_by_key(|(_, v)| v.get("atMs").and_then(|a| a.as_u64()).unwrap_or(0))+            .map(|(k, _)| k.clone());+        match oldest {+            Some(o) => {+                m.remove(&o);+            }+            None => break,+        }+    }+    let _ = crate::launch::write_atomic(&path(), &Value::Object(m).to_string());+}++/// `Some(true)`: this session has held a document. `Some(false)`: the bridge+/// launched it and has watched it since, and it never has. `None`: unknown.+pub fn ever_had_document(pid: u32, created: Option<u64>) -> Option<bool> {+    if load().get(&key(pid, created)).and_then(|v| v.get("everHadDocument")).and_then(|v| v.as_bool())+        == Some(true)+    {+        return Some(true);+    }+    match crate::launch::attributed_solidworks() {+        Some((p, c)) if p == pid && Some(c) == created => Some(false),+        _ => None,+    }+}++/// After a verb: if its reply shows a document open in the running session,+/// record it. Asks the host which session that is only when there is something+/// to record.+pub fn note_reply(ctx: &crate::verbs::Ctx, command: &str, reply: &Value) {+    if reply.get("success").and_then(|v| v.as_bool()) == Some(false) {+        return;+    }+    let made_one = matches!(+        command,+        "swx_new_document" | "swx_open" | "swx_import" | "swx_add_component" | "swx_show_part"+            | "swx_show_assembly" | "swx_show_drawing"+    );+    let saw_one = ["documentCount", "count", "openDocuments"]+        .iter()+        .any(|k| reply.get(*k).and_then(|v| v.as_u64()).unwrap_or(0) > 0);+    if !(made_one || saw_one) {+        return;+    }+    let Ok(state) = ctx.call("startup_state", json!({}), 20) else { return };+    let Some(pid) = state.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32) else { return };+    let created = crate::launchos::os()+        .processes()+        .ok()+        .and_then(|ps| ps.iter().find(|p| p.pid == pid).and_then(|p| p.created_at));+    mark(pid, created);+}
src/verbs.rs+306−119
@@ -1437,6 +1437,7 @@ fn swx_stop(ctx: &Ctx, args: &Value) -> Value {                         .into(),                 ),                 host_error: None,+                primed: false,             });             continue;         }@@ -1497,127 +1498,47 @@ fn swx_stop(ctx: &Ctx, args: &Value) -> Value {                 return out;             }         }-        let rung_started = std::time::Instant::now();-        // RELEASE EVERY REFERENCE FIRST. Rung (a) also ends by releasing; this-        // makes rungs (b)-(f) START released, which is the ladder-wide invariant.-        let _ = ctx.call("release_com", json!({}), 20);-        let asked = ctx.call(-            "quit_rung",-            json!({-                "rung": rung,-                "mainHwnd": id.main_hwnd,-                "processId": id.pid,-                "processCreatedAt": id.created_at,-            }),-            quit::rung_budget_secs(rung) + 20,-        );-        let (host_word, detail, host_error) = match &asked {-            Ok(r) => (-                r.get("result").and_then(|v| v.as_str()).unwrap_or("").to_string(),-                r.get("detail").and_then(|v| v.as_str()).map(String::from),-                None,-            ),-            // A rung that WORKED tears the COM server down underneath the call-            // that asked for it, so a transport failure here is not a verdict —-            // the census below decides.-            Err(e) => (String::new(), None, Some(e.message.clone())),-        };-        // ── MEASURE, and never with a COM call. Release again first so nothing-        //    this process holds can keep the app alive while we ask.-        let _ = ctx.call("release_com", json!({}), 10);-        let refs = ctx-            .call("com_refs", json!({}), 10)-            .ok()-            .and_then(|r| r.get("outstanding").and_then(|v| v.as_u64()))-            .unwrap_or(0);-        // ── WAIT FOR THE EXIT, don't sample once for it. `ExitApp` RETURNS and-        //    SOLIDWORKS then takes seconds to tear a session down (10 CEF-        //    subprocesses, three out-of-process servers, the journal). A single-        //    reading 1.5 s later called that "declined" — and the ladder's-        //    response to a decline is to escalate, sending a window message into-        //    an application that was already exiting. So poll at the settle-        //    interval up to THIS rung's own budget, and stop early on the two-        //    answers that end the ladder: gone, or a dialog on screen.-        let interval = std::time::Duration::from_millis(-            std::env::var("SWX_QUIT_SETTLE_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500),-        );-        // How long to WAIT for the exit: this rung's own budget, because that is-        // the longest the rung is allowed to take at all. SWX_QUIT_EXIT_WAIT_SECS-        // shortens it — the suite and the gate both drive many rungs and neither-        // needs a real half-minute to watch a simulated process go.-        let wait_secs = std::env::var("SWX_QUIT_EXIT_WAIT_SECS")-            .ok()-            .and_then(|v| v.parse::<u64>().ok())-            .unwrap_or_else(|| quit::rung_budget_secs(rung));-        let wait_until = std::time::Instant::now() + std::time::Duration::from_secs(wait_secs);-        let mut census;-        let mut gone;-        let mut after_windows;-        let mut new_dialog;-        let mut polls = 0u32;-        loop {-            std::thread::sleep(interval);-            polls += 1;-            census = os.processes();-            gone = quit::process_gone(census.as_ref(), &id);-            after_windows = os.windows_of_pid(id.pid);-            new_dialog = after_windows.as_ref().ok().and_then(|w| {-                quit::first_blocking_dialog(w).map(|mut d| {-                    d.read_contents(&*os);-                    d-                })-            });-            if gone == Some(true) || new_dialog.is_some() || std::time::Instant::now() >= wait_until-            {-                break;+        let mut attempt = run_rung(ctx, os.as_ref(), &id, rung);+        // ── PRIME A NEVER-HAD-A-DOCUMENT SESSION, THEN ASK AGAIN. MEASURED+        //    2026-10-01 (pid 20144): rung (a) was declined three times on a+        //    session that had never held a document, and quit in 3.4 s once one+        //    scratch part had been created and closed in it. So on rung (a) only,+        //    under the user's authorization, on an EMPTY session with no dialog,+        //    whose history says it never held a document (or cannot say): create+        //    one bridge-owned scratch part, close it, and ExitApp once more.+        if *rung == "a"+            && attempt.result == quit::RungResult::Declined+            && attempt.dialog.is_none()+            && user_authorized+        {+            match prime_session(ctx, os.as_ref(), &id) {+                Ok(prime) => {+                    crate::ledger::record(+                        "solidworks_quit_rung",+                        json!({"rung": rung, "result": attempt.result.name(), "processGone": attempt.process_gone}),+                    );+                    tries.push(attempt);+                    attempt = run_rung(ctx, os.as_ref(), &id, rung);+                    attempt.primed = true;+                    attempt.detail = Some(format!(+                        "{} — after PRIMING the session (one bridge-owned scratch part created and \+                         closed: {})",+                        attempt.detail.clone().unwrap_or_default(),+                        prime+                    ));+                }+                Err(why) => {+                    attempt.detail = Some(format!(+                        "{} — not primed: {why}",+                        attempt.detail.clone().unwrap_or_default()+                    ));+                }             }         }-        let (children, survivors, unjudged) =-            census.as_ref().ok().map(|c| quit::reap_report(c)).unwrap_or_default();-        let new_windows = after_windows-            .as_ref()-            .ok()-            .map(|w| w.iter().filter(|x| x.is_dialog() && x.visible).count())-            .unwrap_or(0);--        let mut result = match gone {-            Some(true) => quit::RungResult::Quit,-            Some(false) => quit::result_from_host(&host_word),-            // A rung that cannot be MEASURED is a rung that failed.-            None => quit::RungResult::Unmeasured,-        };-        if result != quit::RungResult::Quit && new_dialog.is_some() {-            result = quit::RungResult::DialogAppeared;-        }-        let mut detail = detail;-        if polls > 1 {-            detail = Some(format!(-                "{} — the exit was measured over {polls} census reads (a rung returns before the \-                 process is gone)",-                detail.unwrap_or_default()-            ));-        }-        if refs > 0 {-            detail = Some(format!(-                "{} — WARNING: {refs} COM reference(s) were still outstanding at the measurement",-                detail.unwrap_or_default()-            ));-        }-        tries.push(quit::RungTry {-            rung,-            action: quit::rung_action(rung),-            result,-            process_gone: gone,-            elapsed_sec: rung_started.elapsed().as_secs_f64(),-            new_windows,-            dialog: new_dialog.clone(),-            children_alive: children,-            survivors_alive: survivors,-            unjudged_alive: unjudged,-            census_readable: census.is_ok(),-            detail,-            host_error,-        });+        let result = attempt.result;+        let new_dialog = attempt.dialog.clone();+        let gone = attempt.process_gone;+        tries.push(attempt);         crate::ledger::record(             "solidworks_quit_rung",             json!({"rung": rung, "result": result.name(), "processGone": gone}),@@ -1758,6 +1679,205 @@ fn swx_docs(ctx: &Ctx, _args: &Value) -> Value {  // ── verbs: documents ──────────────────────────────────────────────────────── +/// ONE RUNG OF THE QUIT LADDER, sent and MEASURED: release every reference, ask,+/// release again, then poll the process census up to the rung's budget for the+/// two answers that end the ladder (gone, or a dialog). Never a COM call to+/// measure. The caller records it.+fn run_rung(+    ctx: &Ctx,+    os: &dyn crate::launchos::LaunchOs,+    id: &crate::quit::Identity,+    rung: &'static str,+) -> crate::quit::RungTry {+    use crate::quit;+    let rung_started = std::time::Instant::now();+    // RELEASE EVERY REFERENCE FIRST. Rung (a) also ends by releasing; this+    // makes rungs (b)-(f) START released, which is the ladder-wide invariant.+    let _ = ctx.call("release_com", json!({}), 20);+    let asked = ctx.call(+        "quit_rung",+        json!({+            "rung": rung,+            "mainHwnd": id.main_hwnd,+            "processId": id.pid,+            "processCreatedAt": id.created_at,+        }),+        quit::rung_budget_secs(rung) + 20,+    );+    let (host_word, detail, host_error) = match &asked {+        Ok(r) => (+            r.get("result").and_then(|v| v.as_str()).unwrap_or("").to_string(),+            r.get("detail").and_then(|v| v.as_str()).map(String::from),+            None,+        ),+        // A rung that WORKED tears the COM server down underneath the call+        // that asked for it, so a transport failure here is not a verdict —+        // the census below decides.+        Err(e) => (String::new(), None, Some(e.message.clone())),+    };+    // ── MEASURE, and never with a COM call. Release again first so nothing+    //    this process holds can keep the app alive while we ask.+    let _ = ctx.call("release_com", json!({}), 10);+    let refs = ctx+        .call("com_refs", json!({}), 10)+        .ok()+        .and_then(|r| r.get("outstanding").and_then(|v| v.as_u64()))+        .unwrap_or(0);+    // ── WAIT FOR THE EXIT, don't sample once for it. `ExitApp` RETURNS and+    //    SOLIDWORKS then takes seconds to tear a session down (10 CEF+    //    subprocesses, three out-of-process servers, the journal). A single+    //    reading 1.5 s later called that "declined" — and the ladder's+    //    response to a decline is to escalate, sending a window message into+    //    an application that was already exiting. So poll at the settle+    //    interval up to THIS rung's own budget, and stop early on the two+    //    answers that end the ladder: gone, or a dialog on screen.+    let interval = std::time::Duration::from_millis(+        std::env::var("SWX_QUIT_SETTLE_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500),+    );+    // How long to WAIT for the exit: this rung's own budget, because that is+    // the longest the rung is allowed to take at all. SWX_QUIT_EXIT_WAIT_SECS+    // shortens it — the suite and the gate both drive many rungs and neither+    // needs a real half-minute to watch a simulated process go.+    let wait_secs = std::env::var("SWX_QUIT_EXIT_WAIT_SECS")+        .ok()+        .and_then(|v| v.parse::<u64>().ok())+        .unwrap_or_else(|| quit::rung_budget_secs(rung));+    let wait_until = std::time::Instant::now() + std::time::Duration::from_secs(wait_secs);+    let mut census;+    let mut gone;+    let mut after_windows;+    let mut new_dialog;+    let mut polls = 0u32;+    loop {+        std::thread::sleep(interval);+        polls += 1;+        census = os.processes();+        gone = quit::process_gone(census.as_ref(), &id);+        after_windows = os.windows_of_pid(id.pid);+        new_dialog = after_windows.as_ref().ok().and_then(|w| {+            quit::first_blocking_dialog(w).map(|mut d| {+                d.read_contents(&*os);+                d+            })+        });+        if gone == Some(true) || new_dialog.is_some() || std::time::Instant::now() >= wait_until+        {+            break;+        }+    }+    let (children, survivors, unjudged) =+        census.as_ref().ok().map(|c| quit::reap_report(c)).unwrap_or_default();+    let new_windows = after_windows+        .as_ref()+        .ok()+        .map(|w| w.iter().filter(|x| x.is_dialog() && x.visible).count())+        .unwrap_or(0);++    let mut result = match gone {+        Some(true) => quit::RungResult::Quit,+        Some(false) => quit::result_from_host(&host_word),+        // A rung that cannot be MEASURED is a rung that failed.+        None => quit::RungResult::Unmeasured,+    };+    if result != quit::RungResult::Quit && new_dialog.is_some() {+        result = quit::RungResult::DialogAppeared;+    }+    let mut detail = detail;+    if polls > 1 {+        detail = Some(format!(+            "{} — the exit was measured over {polls} census reads (a rung returns before the \+             process is gone)",+            detail.unwrap_or_default()+        ));+    }+    if refs > 0 {+        detail = Some(format!(+            "{} — WARNING: {refs} COM reference(s) were still outstanding at the measurement",+            detail.unwrap_or_default()+        ));+    }++    quit::RungTry {+        rung,+        action: quit::rung_action(rung),+        result,+        process_gone: gone,+        elapsed_sec: rung_started.elapsed().as_secs_f64(),+        new_windows,+        dialog: new_dialog.clone(),+        children_alive: children,+        survivors_alive: survivors,+        unjudged_alive: unjudged,+        census_readable: census.is_ok(),+        detail,+        host_error,+        primed: false,+    }+}++/// PRIME A SESSION THAT HAS NEVER HELD A DOCUMENT: one bridge-owned scratch part,+/// created and closed through the ordinary owned-document path (default posture,+/// registered, ledgered `quit_prime`). Refused — `Err` with the reason — unless+/// the session is EMPTY, NO dialog is up, and its history says it never held a+/// document or cannot say. Never with a document open: priming is only ever done+/// to a session nobody is working in.+fn prime_session(+    ctx: &Ctx,+    os: &dyn crate::launchos::LaunchOs,+    id: &crate::quit::Identity,+) -> Result<String, String> {+    use crate::quit;+    let ever = crate::session_history::ever_had_document(id.pid, Some(id.created_at));+    if ever == Some(true) {+        return Err("this session has held documents, so its decline is not the \+                    never-had-a-document kind priming answers"+            .into());+    }+    let count = ctx+        .call("app_state", json!({}), 20)+        .ok()+        .and_then(|s| s.get("documentCount").and_then(|v| v.as_u64()));+    if count != Some(0) {+        return Err(format!(+            "the session is not provably empty (documents open: {}) — a session somebody may \+             be working in is never primed",+            count.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())+        ));+    }+    match os.windows_of_pid(id.pid) {+        Ok(w) if quit::first_blocking_dialog(&w).is_none() => {}+        Ok(_) => return Err("a dialog is up".into()),+        Err(e) => return Err(format!("the window census could not be read: {e}")),+    }+    let made = crate::verbs3::new_document(ctx, &json!({"type": "part"}));+    if made.get("success").and_then(|v| v.as_bool()) != Some(true) {+        return Err(format!("the scratch part could not be created: {}", made.get("error").cloned().unwrap_or(Value::Null)));+    }+    let title = made.pointer("/created/title").and_then(|v| v.as_str()).unwrap_or("").to_string();+    crate::session_history::mark(id.pid, Some(id.created_at));+    let closed = crate::verbs3::reset_workspace(ctx, &json!({}));+    let after = ctx+        .call("app_state", json!({}), 20)+        .ok()+        .and_then(|s| s.get("documentCount").and_then(|v| v.as_u64()));+    crate::ledger::record(+        "quit_prime",+        json!({"created": title, "closed": closed.get("closed").cloned().unwrap_or(Value::Null),+               "documentCountAfter": after, "everHadDocumentBefore": ever,+               "why": "ExitApp was declined on an empty session that had never held a document \+                       (measured 2026-10-01: such a session quits once one has been opened and \+                       closed)"}),+    );+    if after != Some(0) {+        return Err(format!(+            "the scratch part '{title}' was created but the session is not empty afterwards \+             (documents open: {}) — ExitApp is not asked again",+            after.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())+        ));+    }+    Ok(format!("'{title}'"))+}+ fn swx_open(ctx: &Ctx, args: &Value) -> Value {     gate!(ctx);     let path = match arg_str(args, "path") {@@ -2873,6 +2993,26 @@ fn swx_describe(_ctx: &Ctx, _args: &Value) -> Value {  // ── dispatch ──────────────────────────────────────────────────────────────── +/// The verbs that wait for SOLIDWORKS to finish starting (`readiness`): every+/// one that creates, opens, saves or closes a document, and the quit.+pub const STARTUP_GATED: &[&str] = &[+    "swx_new_document",+    "swx_open",+    "swx_import",+    "swx_save",+    "swx_save_as",+    "swx_run_script",+    "swx_run_macro",+    "swx_add_component",+    "swx_close_doc",+    "swx_close_all_documents",+    "swx_close_all_safely",+    "swx_close_import_temps",+    "swx_reset_workspace",+    "swx_shutdown",+    "swx_stop",+];+ pub fn is_known(command: &str) -> bool {     CATALOG.iter().any(|(v, _, _)| *v == command) }@@ -2971,7 +3111,54 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value {         }     }; +    // NOT BEFORE SOLIDWORKS HAS FINISHED STARTING. Every verb that creates, opens,+    // saves or closes a document — and the quit — waits for readiness: measured+    // 2026-10-01, document commands sent 8 s after COM attach crashed SOLIDWORKS+    // while its 3DEXPERIENCE add-in was still initialising. See src/readiness.rs.+    if STARTUP_GATED.contains(&command) {+        if let Some(refusal) = crate::readiness::refuse_until_ready(ctx, command) {+            return refusal;+        }+    }     let mut out = handler(ctx, args);+    // A SESSION'S HISTORY: whether it has ever held a document decides how the+    // quit ladder treats a declined ExitApp (see src/session_history.rs).+    crate::session_history::note_reply(ctx, command, &out);+    // `attached` IS NOT `ready`: swx_start and swx_status say which, and on what.+    if command == "swx_start" || command == "swx_status" {+        let attached = out.get("attached").and_then(|v| v.as_bool()) == Some(true)+            || out.get("stage").and_then(|v| v.as_str()) == Some("attached")+            || out.pointer("/com/running").and_then(|v| v.as_bool()) == Some(true);+        if attached && out.get("success").and_then(|v| v.as_bool()) != Some(false) {+            let r = crate::readiness::assess(ctx);+            let ready = r.get("ready").and_then(|v| v.as_bool());+            if let Value::Object(map) = &mut out {+                if command == "swx_start" {+                    match ready {+                        Some(true) => {+                            map.insert("stage".into(), json!("ready"));+                        }+                        Some(false) => {+                            map.insert("stage".into(), json!("starting"));+                            map.insert("stillStarting".into(), json!(true));+                            map.insert(+                                "_readyHint".into(),+                                json!("COM is attached but SOLIDWORKS has not finished starting. \+                                       Document verbs refuse with host_app_starting until it has: \+                                       poll swx_status until `solidworks.ready` is true."),+                            );+                        }+                        None => {}+                    }+                    map.insert("ready".into(), json!(ready));+                    map.insert("readiness".into(), r);+                } else if let Some(Value::Object(sw)) = map.get_mut("solidworks") {+                    sw.insert("ready".into(), json!(ready));+                    sw.insert("readiness".into(), r);+                }+            }+        }+    }     // A failure always tells the caller how to report it — real machines are     // the only place the COM layer can actually be proven.     if out.get("success").and_then(|v| v.as_bool()) == Some(false) {
src/verbs3.rs+74
@@ -1143,6 +1143,47 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {                           itself, having first written every one of them to disk",         }),     );+    // SOLIDWORKS IS GONE — SAY SO. Measured 2026-10-01: SOLIDWORKS crashed in the+    // middle of a sweep and the reply said `dialog_census_unknown`, which is true+    // of a dead process and tells the reader nothing. When the sweep stopped+    // short (a host failure, or a census nobody could read) and SOLIDWORKS is no+    // longer running, the answer is `host_app_gone`: what was preserved (and+    // verified) before it went, what was closed, and what SOLIDWORKS wrote in its+    // performance log on the way down.+    let stopped_short = !close_failed.is_empty() || dialog_unreadable;+    if stopped_short && (before.is_some() || !recovered.is_empty()) && solidworks_gone(ctx) {+        let preserved = recovered.iter().filter(|r| r["preserved"] == true).count();+        let evidence = crate::perflog::crash_evidence();+        crate::ledger::record(+            "host_app_gone",+            json!({"during": "close_all_safely", "recoveryDir": dir_s, "preserved": preserved,+                   "closed": closed, "crashEvidence": evidence}),+        );+        let mut out = err_code(+            "host_app_gone",+            format!(+                "SOLIDWORKS stopped running during the sweep. Before it went, {preserved} \+                 document(s) were preserved (each verified on disk) and {closed} closed."+            ),+            "SOLIDWORKS is gone, not blocked: there is no dialog to look for. Tell the user what \+             was preserved and where (swx_recover_list), quote `crashEvidence` if it is not \+             empty, and let them restart SOLIDWORKS — its own Document Recovery may offer \+             more. Wait for swx_status to report `solidworks.ready` before any document verb.",+        );+        if let Value::Object(m) = &mut out {+            m.insert("recoveryDir".into(), json!(dir_s));+            m.insert("recovered".into(), json!(recovered));+            m.insert("preserved".into(), json!(preserved));+            m.insert("closed".into(), json!(closed));+            m.insert("closeFailed".into(), json!(close_failed));+            m.insert("crashEvidence".into(), json!(evidence));+            m.insert(+                "performanceLog".into(),+                crate::perflog::path().map(|p| json!(p.to_string_lossy())).unwrap_or(Value::Null),+            );+        }+        return out;+    }     let remaining: Vec<Value> = recovered         .iter()         .filter(|r| r.get("preserved").and_then(|v| v.as_bool()) == Some(false))@@ -1481,6 +1522,20 @@ fn closed_in_place_entry(e: &Value) -> Option<Value> {     })) } +/// IS SOLIDWORKS STILL THERE? Asked only after a sweep stopped short. COM first+/// (the host's `startup_state` reports `running:false` when there is no+/// SOLIDWORKS to attach to); if the host cannot answer, the process census. An+/// unreadable census is NOT "gone".+fn solidworks_gone(ctx: &Ctx) -> bool {+    match ctx.call("startup_state", json!({}), 20) {+        Ok(s) => s.get("running").and_then(|v| v.as_bool()) == Some(false),+        Err(_) => crate::launchos::os()+            .processes()+            .map(|ps| !ps.iter().any(|p| p.is("sldworks.exe")))+            .unwrap_or(false),+    }+}+ /// `swx_recover_list` — what was preserved, and where. Read-only. pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {     let _ = ctx;@@ -1523,6 +1578,7 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {     {         for (d, location) in dirs {             let mut files: Vec<Value> = Vec::new();+            let mut lock_files: Vec<Value> = Vec::new();             // THE SWEEP'S OWN RECORD, IF IT LEFT ONE. Re-deriving a verdict from             // the file alone cannot see the two facts that decide the hard cases:             // what the original document weighed, and whether the extension was a@@ -1557,6 +1613,23 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {                     if name == MANIFEST_NAME || name == "manifest.json.tmp" {                         continue;                     }+                    // NOR IS A LOCK FILE. SOLIDWORKS keeps `~$<name>` beside a+                    // document while it is open — and a rescue IS open at its+                    // recovery path until the sweep closes it, or forever if+                    // SOLIDWORKS died first (measured 2026-10-01). Listing it as an+                    // unverified file read as a failed rescue. It is named apart.+                    if name.starts_with("~$") {+                        lock_files.push(json!({+                            "name": name,+                            "lockFile": true,+                            "lockOf": &name[2..],+                            "note": "SOLIDWORKS' lock file for a document open at this path: not a \+                                     rescue, and not a failed one. It disappears when the document \+                                     is closed; one left behind means SOLIDWORKS stopped while it \+                                     was open.",+                        }));+                        continue;+                    }                     let bytes = f.metadata().ok().map(|m| m.len());                     // A MIGRATION'S HALF-COPIED TEMPORARY (see `migrate_legacy`):                     // incomplete by definition, so it is never a rescue — but it@@ -1925,6 +1998,7 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {                     )),                 },                 "files": files,+                "lockFiles": lock_files,                 "closedInPlace": in_place,             }));         }
tests/adversary39.rsadded+289
@@ -0,0 +1,289 @@+//! ADVERSARIAL REVIEW OF 0.9.2 — pass forty-one, the merge code only.+//!+//! WHAT CHECKS OUT+//!+//!  * NO PING-PONG. After a merge the record keeps `legacySha256` = the digest of+//!    the INCOMING legacy bytes, not of the merged live file, so an unchanged+//!    legacy file compares equal on the next start; and the record of the copy+//!    that held the real name follows its own bytes beside. Pinned below by+//!    starting twice: one merge, no new side files, and the second start's live+//!    ledger differs from the first only by the second start's own lines.+//!  * THE 0.8.x LINE FORMAT is the 0.9.x one. `at` has been `as_secs()` epoch+//!    seconds since v0.3.0 and serde_json has never had `preserve_order`, so a+//!    copied line is byte-identical to its original and the cross-file dedupe+//!    is exact. Equal `at` from two roots sorts STABLY, live first — deterministic,+//!    though second resolution cannot recover the true interleaving.+//!  * A malformed or `at`-less line takes the `at` of the line before it in its+//!    own file; at the head of a file that is 0 and it sorts first. Harmless.+//!  * REGISTRY UNION is safe for the case asked: two `Part3` claims from two+//!    eras own nothing in a third session — `owns_doc` requires+//!    `d.session == session` before any title or path match, and `reconcile`+//!    drops a pid whose recorded start time differs from the live one. The union+//!    only carries claims forward; 0.9.1's pick already carried 0.8.9's. A claim+//!    whose scratch file is gone is inert: nothing open has that path. The+//!    registry is `{"documents": [...]}` and nothing else, so the union loses no+//!    field.+//!  * `recovery_dir_unwritable` precedes the headroom check and the dialog+//!    census, and neither failure point in `recovery_dir()` creates anything;+//!    the sweep's end-of-run cleanup removes an unused manifest and an empty+//!    folder for any later refusal.+//!  * THE HASH CAP is `<=`, so exactly 16 MiB is hashed and 16 MiB + 1 is not —+//!    as documented and recorded in `comparedBy`; pinned below. A file that grows+//!    past the cap between the metadata read and the hash is hashed AS IT IS NOW+//!    (streamed to EOF): the digest can only disagree with the record, so the+//!    race can produce "changed" and never a false "same". Safe direction.++mod common;+use common::*;+use serde_json::{json, Value};+use std::path::{Path, PathBuf};+use std::time::{Duration, SystemTime};+use swx_bridge::scratch::merge_ledger_lines;++fn write(path: &Path, bytes: &[u8]) {+    if let Some(p) = path.parent() {+        std::fs::create_dir_all(p).unwrap();+    }+    std::fs::write(path, bytes).unwrap();+}++fn set_mtime(path: &Path, t: SystemTime) {+    std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();+}++struct Layout {+    local: TempDir,+}+impl Layout {+    fn new(tag: &str) -> Layout {+        Layout { local: TempDir::new(tag) }+    }+    fn data(&self) -> PathBuf {+        self.local.0.join("adom-solidworks-bridge")+    }+    fn cache(&self) -> PathBuf {+        self.local.0.join("Adom Bridge").join("bridges-cache").join("solidworks")+    }+    fn v090(&self) -> PathBuf {+        self.local.0.join("Adom Bridge").join("solidworks-data")+    }+    fn env(&self) -> [(&'static str, String); 2] {+        [("SWX_SCRATCH_DIR", String::new()), ("LOCALAPPDATA", self.local.path())]+    }+}++fn start(lay: &Layout) -> Bridge {+    let e = lay.env();+    Bridge::start_with(&[(e[0].0, e[0].1.as_str()), (e[1].0, e[1].1.as_str())])+}++/// A ledger line exactly as `ledger::record_in` writes it: serde_json's default+/// map is sorted, so the keys come out `action`, `at`, `detail`.+fn line(at: u64, action: &str, detail: Value) -> String {+    json!({"at": at, "action": action, "detail": detail}).to_string()+}++/// SEVERITY 2 — THE MERGE COLLAPSES REPEATED REAL EVENTS, IN THE WHOLE LIVE+/// LEDGER.+///+/// `merge_ledger_lines` dedupes through ONE `HashSet` shared across both inputs+/// AND within each. Across inputs that is the point: a line present in both+/// files is one event copied. WITHIN one file it is a loss: two identical lines+/// there are two events. A ledger line is `{"at": <epoch SECONDS>, "action",+/// "detail"}` with no sequence and no id, and several details carry nothing that+/// tells two events apart:+///+///   * `document_closed` from `swx_reset_workspace` records `{"title", "by"}` —+///     no path. Two imports of one STEP are both titled `smoke.SLDPRT` (pass 31+///     measured this), each has its own file since 0.6.0, so the reset closes+///     both, by path, in the same second: two identical lines.+///   * `parameter_set {name, value}` — an agent setting a dimension twice to the+///     same value in a second (a retry, a rebuild loop).+///   * `document_activated {name}`.+///+/// And the set is applied to the LIVE file, not just the legacy one: the first+/// time ANY legacy version has to be merged, every repeated event in the live+/// audit log since install is collapsed to one, permanently. Afterwards the+/// ledger says one document was closed where two were — about exactly the+/// action the ledger exists to record.+///+/// Fix: a multiset merge — each distinct line max(count in live, count in+/// incoming) times — which keeps copied events once and repeated events every+/// time. Going forward, a per-line sequence (or ms + pid) makes lines distinct.+#[test]+fn a_repeated_event_within_one_ledger_survives_the_merge() {+    let closed = line(1_790_900_000, "document_closed", json!({"title": "smoke.SLDPRT", "by": "reset_workspace"}));+    let before = line(1_790_899_000, "document_created", json!({"title": "smoke.SLDPRT"}));+    // THE LIVE LEDGER: two imports of one STEP, both closed in the same second.+    let live = format!("{before}\n{closed}\n{closed}\n");+    // THE LEGACY VERSION: copied before the second close happened.+    let incoming = format!("{before}\n{closed}\n");++    let (merged, n) = merge_ledger_lines(&live, &incoming);+    let closes = merged.lines().filter(|l| *l == closed).count();+    assert!(n >= 2, "precondition: the merge produced a ledger: {merged:?}");+    assert_eq!(+        closes, 2,+        "two documents were closed — the live ledger says so, twice — and the merged ledger \+         records ONE. merge_ledger_lines dedupes through a single set shared within each file, \+         and a ledger line has no sequence: `document_closed` from swx_reset_workspace carries \+         only {{title, by}}, so two same-titled imports closed in one second are byte-identical. \+         The set is applied to the LIVE file too, so the first merge collapses every repeated \+         event since install. Merge as a multiset: max(count in live, count in incoming).\n  \+         merged: {merged:?}"+    );+}++/// SEVERITY 3 — THE UNION IS CUT BACK TO 2,000 LINES BY THE NEXT WRITE ON THE+/// SAME START.+///+/// `LEDGER_MAX_LINES` is 2,000 and `record_in` drains the OLDEST lines past it.+/// The merge writes the full union — up to two caps' worth — and then, on the+/// same start, `server.rs` records `data_migrated_from_install_cache` and+/// `legacy_files_merged`. The first of those trims the live ledger back to+/// 2,000, ordered by `at`, so the oldest history in the union — mostly the older+/// era — leaves the live file at once. The migration record still says+/// `entries: <the union>`, so the record and the file disagree from the moment+/// the bridge is up. Nothing is lost on disk — both versions were copied beside+/// first — but the merge was introduced so that the LIVE ledger holds every+/// history, and past the cap it does not. Either exempt the migration start from+/// the cap, or record in the merge entry how many lines the cap removed and that+/// they are in the side copies.+#[test]+fn a_merged_ledger_is_not_truncated_by_the_cap_on_the_same_start() {+    let lay = Layout::new("adv39-cap");+    let mut a = String::new();+    let mut b = String::new();+    for i in 0..1_500u64 {+        a.push_str(&line(1_780_000_000 + i, "parameter_set", json!({"name": format!("A{i}"), "value": i})));+        a.push('\n');+        b.push_str(&line(1_790_000_000 + i, "parameter_set", json!({"name": format!("B{i}"), "value": i})));+        b.push('\n');+    }+    write(&lay.cache().join("ledger.jsonl"), a.as_bytes());+    write(&lay.v090().join("ledger.jsonl"), b.as_bytes());++    let br = start(&lay);+    let _ = br.call("swx_recover_list", json!({}));+    drop(br);++    let live = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();+    assert!(live.contains("\"B1499\""), "precondition: the merge ran: {} lines", live.lines().count());+    let first_era = live.lines().filter(|l| l.contains("\"name\":\"A")).count();+    assert_eq!(+        first_era,+        1_500,+        "the union of two 1,500-line ledgers was written, and the very next ledger write on the \+         same start cut the live file back to LEDGER_MAX_LINES (2,000), dropping {} of the older \+         era's lines. They survive only in the side copies; the merge record still counts the \+         union. The merge exists so the LIVE ledger holds every history. live now has {} lines.",+        1_500 - first_era,+        live.lines().count()+    );+}++/// PIN — THE MERGE IS IDEMPOTENT. Passes today; the coordinator's target (2).+/// Two versions of the ledger and the registry, two starts: one merge each,+/// recorded once, and no new side files on the second start.+#[test]+fn a_merge_happens_once_and_the_second_start_changes_nothing() {+    let lay = Layout::new("adv39-idem");+    let old = SystemTime::now() - Duration::from_secs(86_400);+    write(&lay.cache().join("ledger.jsonl"), format!("{}\n", line(1_789_000_000, "document_created", json!({"title": "CACHE"}))).as_bytes());+    write(&lay.v090().join("ledger.jsonl"), format!("{}\n", line(1_790_000_000, "document_created", json!({"title": "V090"}))).as_bytes());+    write(&lay.cache().join("owned-docs.json"), br#"{"documents":[{"title":"Part1","path":"C:\\a\\Part1-1.SLDPRT","session":11}]}"#);+    write(&lay.v090().join("owned-docs.json"), br#"{"documents":[{"title":"Part2","path":"C:\\b\\Part2-2.SLDPRT","session":22}]}"#);+    for p in [lay.cache().join("ledger.jsonl"), lay.v090().join("ledger.jsonl"),+              lay.cache().join("owned-docs.json"), lay.v090().join("owned-docs.json")] {+        set_mtime(&p, old);+    }++    let side = |d: &Path| -> usize {+        std::fs::read_dir(d)+            .map(|rd| rd.flatten().filter(|e| e.file_name().to_string_lossy().contains(".legacy-")).count())+            .unwrap_or(0)+    };+    let merges = |l: &str| l.lines().filter(|x| x.contains("\"action\":\"legacy_files_merged\"")).count();++    let b1 = start(&lay);+    let _ = b1.call("swx_recover_list", json!({}));+    drop(b1);+    let ledger1 = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();+    let reg1 = std::fs::read_to_string(lay.data().join("owned-docs.json")).unwrap_or_default();+    let side1 = side(&lay.data());+    assert!(ledger1.contains("CACHE") && ledger1.contains("V090"), "first start merged the ledgers: {ledger1:?}");+    assert!(reg1.contains("Part1") && reg1.contains("Part2"), "first start merged the registries: {reg1:?}");+    assert!(side1 > 0, "precondition: both versions were copied beside");+    // NOT VACUOUS: the count compared below must start above zero, or "equal"+    // would be 0 == 0 — the wrong-reason pass this review keeps catching.+    assert!(merges(&ledger1) >= 1, "precondition: the merge was recorded in the ledger: {ledger1}");++    let b2 = start(&lay);+    let _ = b2.call("swx_recover_list", json!({}));+    drop(b2);+    let ledger2 = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();+    let reg2 = std::fs::read_to_string(lay.data().join("owned-docs.json")).unwrap_or_default();++    assert_eq!(merges(&ledger1), merges(&ledger2), "the second start merged again: {ledger2}");+    assert_eq!(side1, side(&lay.data()), "the second start copied more versions beside");+    assert!(ledger2.starts_with(ledger1.trim_end_matches('\n')), "the second start rewrote history rather than appending");+    assert_eq!(reg1, reg2, "the second start rewrote the merged registry");+}++/// PIN — THE HASH CAP AT ITS BOUNDARY. Passes today; the coordinator's target (5).+/// A same-size, same-mtime content change is CAUGHT at exactly 16 MiB (hashed)+/// and, as documented, NOT at 16 MiB + 1 (size+mtime only, `comparedBy` says so).+#[test]+fn the_hash_cap_is_inclusive_at_sixteen_mebibytes() {+    use swx_bridge::scratch::MIGRATION_HASH_CAP;+    let lay = Layout::new("adv39-hashcap");+    let t = SystemTime::now() - Duration::from_secs(7_200);+    let at = |n: u64| lay.cache().join("exports").join(format!("blob-{n}.bin"));+    let sizes = [MIGRATION_HASH_CAP, MIGRATION_HASH_CAP + 1];+    for &n in &sizes {+        write(&at(n), &vec![b'a'; n as usize]);+        set_mtime(&at(n), t);+    }+    {+        let b = start(&lay);+        let _ = b.call("swx_recover_list", json!({}));+    }+    // NOT VACUOUS: both blobs must have been copied on the first start, or the+    // "not changed" verdict for the larger one would hold for a file never seen.+    for &n in &sizes {+        let copied = lay.data().join("exports").join(format!("blob-{n}.bin"));+        assert_eq!(+            std::fs::metadata(&copied).map(|m| m.len()).ok(),+            Some(n),+            "precondition: blob-{n}.bin was migrated on the first start"+        );+    }+    for &n in &sizes {+        let mut v = vec![b'a'; n as usize];+        v[(n / 2) as usize] = b'b'; // same length, different bytes+        write(&at(n), &v);+        set_mtime(&at(n), t);+    }+    {+        let b = start(&lay);+        let _ = b.call("swx_recover_list", json!({}));+    }+    let marker = std::fs::read_to_string(lay.data().join("migrated-from-legacy.json")).unwrap_or_default();+    let m: Value = serde_json::from_str(&marker).unwrap_or(Value::Null);+    let changed: Vec<String> = m["changedSinceCopy"]+        .as_array()+        .map(|a| a.iter().filter_map(|e| e["file"].as_str().map(|s| s.to_string())).collect())+        .unwrap_or_default();+    let name = |n: u64| format!("exports/blob-{n}.bin").replace('/', std::path::MAIN_SEPARATOR_STR);+    assert!(+        changed.iter().any(|f| f.ends_with(&format!("blob-{}.bin", MIGRATION_HASH_CAP))),+        "a same-size same-mtime change to a file of EXACTLY the cap ({} bytes) must be caught by \+         the hash: changed = {changed:?}",+        MIGRATION_HASH_CAP+    );+    assert!(+        !changed.iter().any(|f| f.ends_with(&format!("blob-{}.bin", MIGRATION_HASH_CAP + 1))),+        "one byte over the cap is compared by size+mtime only, as documented: changed = {changed:?}"+    );+    let _ = name;+}
tests/quit.rs+89
@@ -575,3 +575,92 @@ fn the_session_changing_between_rungs_is_caught_by_the_per_rung_sweep() {         s(&r)     ); }++// ── PRIMING A NEVER-HAD-A-DOCUMENT SESSION (measured 2026-10-01) ───────────++/// MEASURED 2026-10-01 on Drew's machine (pid 20144): rung (a) was DECLINED three+/// times on a bridge-launched session that had never held a document — 0+/// documents, no dialog, census readable — and quit in 3.4 s on the SAME session+/// once one scratch part had been created and closed. The simulator models that+/// (`exitedIfEverHadDocument`). Under the user's authorization, on an empty+/// session with no dialog, the ladder PRIMES the session — one bridge-owned+/// scratch part, created and closed — and asks again; both attempts are in+/// `rungsTried`, the second `primed:true`, and the prime is ledgered.+fn authorized(max_rung: &str) -> Value {+    json!({"userAuthorized": true, "reason": "the user asked me to quit SOLIDWORKS", "maxRung": max_rung})+}++fn ledger_has(b: &Bridge, action: &str) -> bool {+    b.call("swx_action_ledger", json!({"limit": 200}))["actions"]+        .as_array()+        .map(|a| a.iter().any(|e| e["action"] == action))+        .unwrap_or(false)+}++#[test]+fn a_never_had_a_document_session_is_primed_and_then_quits() {+    let m = Sim::new("quit-prime", json!({"a": "exitedIfEverHadDocument"}));+    let b = m.boot(&[]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_stop", authorized("a"));+    let tries = r["rungsTried"].as_array().cloned().unwrap_or_default();+    check!(b, "swx_stop: the primed session quits on rung a", r["success"] == true && r["stopped"] == true, s(&r));+    check!(+        b,+        "swx_stop: ...after a first ExitApp was declined, and the second attempt says it was primed",+        tries.len() == 2+            && tries[0]["result"] == "declined"+            && tries[0]["primed"] == false+            && tries[1]["result"] == "quit"+            && tries[1]["primed"] == true,+        s(&r)+    );+    check!(b, "swx_stop: the prime is in the ledger", ledger_has(&b, "quit_prime"), String::new());+}++/// Priming needs the user's authorization: under the write opt-in alone, a+/// declined rung (a) is reported as declined and nothing is created.+#[test]+fn priming_needs_the_users_authorization() {+    let m = Sim::new("quit-prime-noauth", json!({"a": "exitedIfEverHadDocument"}));+    let b = m.boot(&[]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_stop", full_args("a"));+    let tries = r["rungsTried"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "swx_stop: without userAuthorized the decline stands, unprimed",+        r["stopped"] == false && tries.len() == 1 && tries[0]["result"] == "declined",+        s(&r)+    );+    check!(b, "swx_stop: ...and no scratch part was made to prime it", !ledger_has(&b, "quit_prime"), String::new());+}++/// NEVER PRIME A SESSION SOMEBODY IS WORKING IN. A document that appears between+/// the declined rung and the prime stops it: nothing is created, the user's+/// document stays open, and the attempt says why it was not primed.+#[test]+fn a_session_with_a_document_open_is_never_primed() {+    let m = Sim::new("quit-prime-busy", json!({"a": "exitedIfEverHadDocument"}));+    let b = m.boot(&[]);+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-doc-appears-after-decline"), "").unwrap();+    let r = b.call("swx_stop", authorized("a"));+    let tries = r["rungsTried"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "swx_stop: a document opened meanwhile means NO prime — the decline stands",+        r["stopped"] == false+            && tries.len() == 1+            && tries[0]["detail"].as_str().unwrap_or("").contains("not primed"),+        s(&r)+    );+    check!(b, "swx_stop: ...nothing was created to prime it", !ledger_has(&b, "quit_prime"), String::new());+    let docs = b.call("swx_docs", json!({}));+    check!(+        b,+        "swx_docs: the user's document is still open, untouched",+        s(&docs).contains("opened-meanwhile"),+        s(&docs)+    );+}
tests/readiness.rsadded+277
@@ -0,0 +1,277 @@+//! ATTACHED IS NOT READY — measured 2026-10-01, and pinned here.+//!+//! On Drew's machine a warm launch attached over COM in 29.7 s; `swx_new_document`+//! 8 s later, `swx_open` at 9 s and `swx_close_all_safely` at 12 s CRASHED+//! SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (its+//! performance log: LOAD_ADDIN lines still running, then SWEXH / UNHANDLED, the+//! 3DEXPERIENCE marker only AFTER the document commands, SWERR_REPORT). A control+//! launch with no document commands crashed nothing and logged `<3DXLOAD_TIME>`+//! when the platform load finished. So:+//!+//!   * every document-creating, -opening, -saving or -closing verb, and the quit,+//!     refuses `host_app_starting` until SOLIDWORKS has finished starting;+//!   * `swx_start` reports `stage:"ready"` only then, and `swx_status` exposes+//!     `solidworks.ready` with what it is based on;+//!   * two independent signals: COM's `StartupProcessCompleted`, and the+//!     performance log (a 3DEXPERIENCE marker after the last LOAD_ADDIN line);+//!   * a sweep during which SOLIDWORKS dies says `host_app_gone`, with the log's+//!     crash markers, never `dialog_census_unknown`;+//!   * SOLIDWORKS' `~$` lock files are not offered as rescues.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++fn a_visible_machine(tag: &str) -> TempDir {+    let d = TempDir::new(tag);+    std::fs::write(d.join("windows.json"), "[]").unwrap();+    d+}++fn session(scratch: &TempDir, docs: Value) {+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({"running": true, "untitled": 1, "active": Value::Null, "docs": docs, "files": {}}).to_string(),+    )+    .unwrap();+}++/// Minimal arguments for each gated verb — enough to reach the gate, which comes+/// before every one of them.+fn gated_calls() -> Vec<(&'static str, Value)> {+    let reason = "the user asked for it";+    vec![+        ("swx_new_document", json!({"type": "part"})),+        ("swx_open", json!({"path": "C:\\Users\\drew\\work\\bracket.SLDPRT"})),+        ("swx_import", json!({"path": "C:\\Users\\drew\\work\\x.step"})),+        ("swx_save", json!({})),+        ("swx_save_as", json!({"outputPath": "C:\\out\\x.SLDPRT"})),+        ("swx_run_script", json!({"script": []})),+        ("swx_run_macro", json!({"path": "C:\\x.swp", "reason": reason})),+        ("swx_add_component", json!({"path": "C:\\x.SLDPRT"})),+        ("swx_close_doc", json!({"name": "Part1"})),+        ("swx_close_all_documents", json!({"confirm": true})),+        ("swx_close_all_safely", json!({"reason": reason})),+        ("swx_close_import_temps", json!({"reason": reason})),+        ("swx_reset_workspace", json!({})),+        ("swx_shutdown", json!({"reason": reason})),+        ("swx_stop", json!({"userAuthorized": true, "reason": reason})),+    ]+}++#[test]+fn document_verbs_wait_for_solidworks_to_finish_starting() {+    let scratch = TempDir::new("ready-gate");+    session(&scratch, json!([]));+    std::fs::write(scratch.join("mock-startup-incomplete"), "").unwrap();+    let machine = a_visible_machine("ready-gate-machine");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);++    let started = b.call("swx_start", json!({}));+    check!(+        b,+        "swx_start: attached but still starting — stage `starting`, never `ready`",+        started["success"] == true+            && started["stage"] == "starting"+            && started["ready"] == false+            && started["stillStarting"] == true,+        s(&started)+    );+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "swx_status: `solidworks.ready` is false, and says what it is based on",+        st["solidworks"]["ready"] == false+            && st["solidworks"]["readiness"]["basedOn"]+                .as_array()+                .map(|a| a.iter().any(|x| x == "StartupProcessCompleted"))+                .unwrap_or(false),+        s(&st["solidworks"])+    );+    for (verb, args) in gated_calls() {+        let r = b.call(verb, args);+        check!(+            b,+            &format!("{verb}: refuses host_app_starting with retryAfterSeconds until SOLIDWORKS is ready"),+            r["success"] == false && r["errorCode"] == "host_app_starting" && r["retryAfterSeconds"] == 5,+            s(&r)+        );+    }+    let docs = b.call("swx_docs", json!({}));+    check!(b, "swx_docs: ...and nothing was sent: no document appeared", docs["count"] == 0, s(&docs));++    // SOLIDWORKS FINISHES STARTING.+    std::fs::remove_file(scratch.join("mock-startup-incomplete")).unwrap();+    let again = b.call("swx_start", json!({}));+    check!(b, "swx_start: now `ready`", again["stage"] == "ready" && again["ready"] == true, s(&again));+    let made = b.call("swx_new_document", json!({"type": "part"}));+    check!(b, "swx_new_document: runs once SOLIDWORKS is ready", made["success"] == true, s(&made));+}++/// THE PERFORMANCE LOG IS A SECOND, INDEPENDENT WITNESS. COM saying "complete"+/// is not enough while the log shows the latest start's add-ins still loading;+/// the 3DEXPERIENCE marker after them is what completes it. A log that has never+/// held such a marker cannot say, and is not used.+#[test]+fn the_performance_log_is_a_second_independent_witness() {+    let scratch = TempDir::new("ready-perflog");+    session(&scratch, json!([]));+    let log = scratch.join("SolidWorksPerformance.log");+    // An earlier session finished; this one is loading add-ins.+    std::fs::write(+        &log,+        "<LOAD_ADDIN> old\n<3DXLOAD_TIME>23000</3DXLOAD_TIME>\n<LOAD_ADDIN> SolidWorks Toolbox\n<LOAD_ADDIN> 3DEXPERIENCE\n",+    )+    .unwrap();+    let logp = log.to_string_lossy().to_string();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_PERF_LOG", &logp)]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_new_document", json!({"type": "part"}));+    check!(+        b,+        "swx_new_document: refused while the log shows add-ins still loading, though COM says complete",+        r["errorCode"] == "host_app_starting"+            && r["readiness"]["startupProcessCompleted"] == true+            && r["readiness"]["performanceLog"]["complete"] == false+            && r["readiness"]["basedOn"].as_array().map(|a| a.len() == 2).unwrap_or(false),+        s(&r)+    );+    std::fs::write(+        &log,+        format!("{}<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n", std::fs::read_to_string(&log).unwrap()),+    )+    .unwrap();+    let r = b.call("swx_new_document", json!({"type": "part"}));+    check!(b, "swx_new_document: runs once the 3DEXPERIENCE marker follows the add-ins", r["success"] == true, s(&r));++    // A LOG WITH NO 3DEXPERIENCE MARKER AT ALL cannot judge, and is not used.+    let scratch2 = TempDir::new("ready-perflog-none");+    session(&scratch2, json!([]));+    let log2 = scratch2.join("SolidWorksPerformance.log");+    std::fs::write(&log2, "<LOAD_ADDIN> SolidWorks Toolbox\n").unwrap();+    let logp2 = log2.to_string_lossy().to_string();+    let b2 = Bridge::start_in(TempDir::borrowed(&scratch2.0), &[("SWX_PERF_LOG", &logp2)]);+    // The FIRST assessment says what it was based on; later ones say the session+    // was already found ready.+    let st = b2.call("swx_start", json!({}));+    check!(+        b2,+        "swx_start: a log without a 3DEXPERIENCE marker is not a signal — COM alone decides, and says so",+        st["ready"] == true+            && st["readiness"]["performanceLog"]["complete"].is_null()+            && st["readiness"]["basedOn"] == json!(["StartupProcessCompleted"]),+        s(&st)+    );+    let st2 = b2.call("swx_status", json!({}));+    check!(+        b2,+        "swx_status: ...and a session found ready stays ready",+        st2["solidworks"]["ready"] == true+            && st2["solidworks"]["readiness"]["basedOn"] == json!(["alreadyReadyThisSession"]),+        s(&st2["solidworks"])+    );+}++/// WITH NO SIGNAL AT ALL, a grace after first sight stands in — and says so. It+/// is not "ready" before the grace has passed.+#[test]+fn with_no_readiness_signal_the_grace_stands_in_and_says_so() {+    let scratch = TempDir::new("ready-grace");+    session(&scratch, json!([]));+    std::fs::write(scratch.join("mock-startup-unreadable"), "").unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_new_document", json!({"type": "part"}));+    check!(+        b,+        "swx_new_document: no signal and a session just seen — still starting, based on the grace",+        r["errorCode"] == "host_app_starting"+            && r["readiness"]["basedOn"] == json!(["graceAfterFirstSeen"])+            && r["readiness"]["startupProcessCompleted"].is_null(),+        s(&r)+    );+}++/// SOLIDWORKS DIES MID-SWEEP: `host_app_gone`, with what was preserved (verified)+/// and closed, and the crash markers from its performance log — never+/// `dialog_census_unknown`.+#[test]+fn a_sweep_during_which_solidworks_dies_says_host_app_gone() {+    let scratch = TempDir::new("ready-gone");+    session(+        &scratch,+        json!([+            {"docType": "part", "path": "untitled:a", "title": "Part1", "saved": false, "dirty": true},+            {"docType": "part", "path": "untitled:b", "title": "Part2", "saved": false, "dirty": true}+        ]),+    );+    let log = scratch.join("SolidWorksPerformance.log");+    std::fs::write(+        &log,+        "<SWEXH>an older session's crash</SWEXH>\n<LOAD_ADDIN> 3DEXPERIENCE\n<3DXLOAD_TIME>24000</3DXLOAD_TIME>\n\+         <DOC_OPEN> \".prtdot\"\n<SWEXH>\n<UNHANDLED>\n</SWEXH>\n<RUNTIME>18</RUNTIME>\n<SWERR_REPORT>\n",+    )+    .unwrap();+    let logp = log.to_string_lossy().to_string();+    let machine = a_visible_machine("ready-gone-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path()), ("SWX_PERF_LOG", &logp)],+    );+    b.call("swx_start", json!({}));+    std::fs::write(scratch.join("mock-crash-after-first-save"), "").unwrap();+    let r = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close SOLIDWORKS"}));+    let evidence: Vec<String> = r["crashEvidence"]+        .as_array()+        .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())+        .unwrap_or_default();+    check!(+        b,+        "swx_close_all_safely: SOLIDWORKS died mid-sweep — host_app_gone, not dialog_census_unknown",+        r["success"] == false && r["errorCode"] == "host_app_gone" && !s(&r).contains("dialogCensusUnknown"),+        s(&r)+    );+    check!(+        b,+        "swx_close_all_safely: ...with what was preserved before it went, and what was closed",+        r["preserved"] == 1 && r["closed"] == 0,+        s(&r)+    );+    check!(+        b,+        "swx_close_all_safely: ...and this session's crash markers from the performance log, not an older session's",+        evidence.iter().any(|l| l.contains("SWERR_REPORT"))+            && evidence.iter().any(|l| l.contains("UNHANDLED"))+            && !evidence.iter().any(|l| l.contains("older session")),+        s(&r["crashEvidence"])+    );+}++/// SOLIDWORKS' `~$` LOCK FILE is not a rescue, and not a failed one.+#[test]+fn a_lock_file_in_a_recovery_folder_is_named_apart() {+    let scratch = TempDir::new("ready-lock");+    let dir = scratch.join("recovery").join("20261001-104300");+    std::fs::create_dir_all(&dir).unwrap();+    let mut body = vec![0x9f, 0xe4, 0x18, 0x9f, 0, 0, 0, 4];+    body.resize(26_129, b'x');+    std::fs::write(dir.join("Part1.SLDPRT"), &body).unwrap();+    std::fs::write(dir.join("~$Part1.SLDPRT"), b"lock").unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    let l = b.call("swx_recover_list", json!({}));+    let sweep = l["sweeps"][0].clone();+    check!(+        b,+        "swx_recover_list: the lock file is listed apart, as a lock file — never as a file to open",+        !sweep["files"].as_array().map(|a| a.iter().any(|f| f["name"] == "~$Part1.SLDPRT")).unwrap_or(true)+            && sweep["lockFiles"][0]["name"] == "~$Part1.SLDPRT"+            && sweep["lockFiles"][0]["lockFile"] == true+            && sweep["lockFiles"][0]["lockOf"] == "Part1.SLDPRT",+        s(&sweep)+    );+}
tests/wiring.rs+36
@@ -126,6 +126,12 @@ const GUARDS: &[(&str, &str)] = &[      "data an older bridge left in ab's install cache is copied out before anything reads the data root"),     ("legacy_roots",      "the listing and the migration still read every location older bridges wrote to"),+    ("refuse_until_ready",+     "no document command reaches a SOLIDWORKS that has not finished starting (crash measured 2026-10-01)"),+    ("prime_session",+     "a declined quit on a never-had-a-document session is primed only when empty and authorized"),+    ("solidworks_gone",+     "a sweep during which SOLIDWORKS dies says host_app_gone, never dialog_census_unknown"),     ("root_is_temp_fallback",      "the only copy of unsaved work never goes to a directory the OS cleans on a schedule"),     ("closed_in_place_entry",@@ -972,3 +978,33 @@ fn acknowledge_only_is_exactly_one_acknowledgement() {         );     } }++/// THE QUIT GATE'S STATUS CLAIM IS NOT VACUOUS (2026-10-01).+///+/// "swx_status no longer reports a running SOLIDWORKS" was written as+/// `running is not True or gone_at is None`, so after a DECLINED quit it printed+/// PASS while the status said `running: true` — twice, on 09-30 and 10-01. The+/// gate's `--self-test` feeds the claim canned replies (a declined quit, an+/// unreadable status, a real quit) and exits non-zero unless it fails the first+/// two and passes only the third. Run here so the claim cannot slide back.+#[test]+fn the_quit_gates_status_claim_fails_on_a_declined_quit() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let out = match std::process::Command::new("python3")+        .arg(root.join("tools").join("quit-gate.py"))+        .arg("--self-test")+        .output()+    {+        Ok(o) => o,+        // No interpreter, no gate to run: not a defect in the gate.+        Err(_) => return,+    };+    let text = String::from_utf8_lossy(&out.stdout).to_string();+    assert!(+        out.status.success() && text.contains("SELF-TEST PASSED"),+        "the quit gate's status claim gave the wrong verdict on a canned reply — it must FAIL \+         when swx_status still says running:true after a declined quit:\n{}{}",+        text,+        String::from_utf8_lossy(&out.stderr)+    );+}
tools/drain-gate.py+13−7
@@ -101,6 +101,8 @@ import sys # (see `verb`); SWX_BRIDGE_URL forces a loopback /command URL instead. The old # default, http://127.0.0.1:24695/command, is dead since adom-bridge 2.1.163. THREAD = "SolidWorks Bridge"+PS_FAST_EMPTY_RETRIES = 3+PS_RETRY_BACKOFF_SECS = 2 FIXTURE_STEM = "DrainGate"  FAILURES = []@@ -115,7 +117,7 @@ class Refused(Exception):     """The preconditions were not met. Nothing was created or closed."""  -def ps(script, reason, timeout=120, _retry=False):+def ps(script, reason, timeout=120, _attempt=0):     enc = base64.b64encode(script.encode("utf-16le")).decode()     r = subprocess.run(         [@@ -136,14 +138,18 @@ def ps(script, reason, timeout=120, _retry=False):     if payload.get("success") is False:         # MEASURED 2026-09-30 (adom-bridge 2.1.163): shell_execute sometimes fails         # FAST (exit 1, under 3 s, empty stdout AND stderr) and the identical-        # command succeeds 5/5 immediately after. That shape is 'could not ask',-        # not an answer, so it is retried ONCE and the retry is printed. Any other-        # failure shape, or a second failure, is still Unreadable.+        # command succeeds immediately after. MEASURED AGAIN 2026-10-01: it+        # happened TWICE IN A ROW (260 ms each). That shape is 'could not ask',+        # not an answer, so it is retried up to THREE times, 2 s apart, and every+        # retry is printed. Any other failure shape, or a fourth failure, is still+        # Unreadable.         fast_empty = (payload.get("exitCode") == 1 and not (payload.get("stdout") or "")                       and not (payload.get("stderr") or "") and (payload.get("durationMs") or 99999) < 5000)-        if fast_empty and not _retry:-            print("  (shell_execute failed fast with no output; retrying once)")-            return ps(script, reason, timeout, _retry=True)+        if fast_empty and _attempt < PS_FAST_EMPTY_RETRIES:+            print("  (shell_execute failed fast with no output; retry %d of %d in %ds)"+                  % (_attempt + 1, PS_FAST_EMPTY_RETRIES, PS_RETRY_BACKOFF_SECS))+            time.sleep(PS_RETRY_BACKOFF_SECS)+            return ps(script, reason, timeout, _attempt=_attempt + 1)         raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])     out = payload.get("stdout")     if out is None:
tools/launch-gate.py+66−30
@@ -56,6 +56,12 @@ import time # (see `verb`); SWX_BRIDGE_URL forces a loopback /command URL instead. The old # default, http://127.0.0.1:24695/command, is dead since adom-bridge 2.1.163. THREAD = "SolidWorks Bridge"+PS_FAST_EMPTY_RETRIES = 3+# How long after the start SOLIDWORKS may take to report READY (attach + add-ins ++# the 3DEXPERIENCE platform load; the control launch of 2026-10-01 logged+# <3DXLOAD_TIME>24000).+READY_BUDGET = 420+PS_RETRY_BACKOFF_SECS = 2 CHAIN = ("CATSTART", "SWXDesktopLauncher") NEVER_TOUCH = ("3DEXPERIENCELauncher", "3DEXPERIENCELauncherSysTray", "3DEXPERIENCELauncherService") @@ -76,7 +82,7 @@ class Unreadable(Exception):     """  -def ps(script, reason, timeout=120, _retry=False):+def ps(script, reason, timeout=120, _attempt=0):     """Run PowerShell on Drew's desktop and return its stdout.      NOTE ON `shell_execute`: this is ab's general escape hatch — it runs an@@ -107,14 +113,18 @@ def ps(script, reason, timeout=120, _retry=False):     if payload.get("success") is False:         # MEASURED 2026-09-30 (adom-bridge 2.1.163): shell_execute sometimes fails         # FAST (exit 1, under 3 s, empty stdout AND stderr) and the identical-        # command succeeds 5/5 immediately after. That shape is 'could not ask',-        # not an answer, so it is retried ONCE and the retry is printed. Any other-        # failure shape, or a second failure, is still Unreadable.+        # command succeeds immediately after. MEASURED AGAIN 2026-10-01: it+        # happened TWICE IN A ROW (260 ms each). That shape is 'could not ask',+        # not an answer, so it is retried up to THREE times, 2 s apart, and every+        # retry is printed. Any other failure shape, or a fourth failure, is still+        # Unreadable.         fast_empty = (payload.get("exitCode") == 1 and not (payload.get("stdout") or "")                       and not (payload.get("stderr") or "") and (payload.get("durationMs") or 99999) < 5000)-        if fast_empty and not _retry:-            print("  (shell_execute failed fast with no output; retrying once)")-            return ps(script, reason, timeout, _retry=True)+        if fast_empty and _attempt < PS_FAST_EMPTY_RETRIES:+            print("  (shell_execute failed fast with no output; retry %d of %d in %ds)"+                  % (_attempt + 1, PS_FAST_EMPTY_RETRIES, PS_RETRY_BACKOFF_SECS))+            time.sleep(PS_RETRY_BACKOFF_SECS)+            return ps(script, reason, timeout, _attempt=_attempt + 1)         raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])     out = payload.get("stdout")     if out is None:@@ -439,27 +449,6 @@ def main():           f"the new launcher log reached 'Run Begin' within {RUN_BEGIN_BUDGET}s",           f"{run_begin_at:.1f}s" if run_begin_at else "never") -    # ── 5. IDEMPOTENCE — three calls during flight ─────────────────────────-    before = probe("counting chains before the idempotence probes")-    chains_before = chain_pids(before)-    logs_before = log_names(before)-    stages = []-    for i in range(3):-        again = verb("swx_start", {"reason": f"launch-gate: idempotence probe {i + 1}"}, 60)-        stages.append((again.get("stage"), again.get("stillLaunching"), again.get("launchedNow")))-        check(again.get("success") is True and again.get("launchedNow") is not True,-              f"swx_start during flight #{i + 1} launched nothing",-              json.dumps(stages[-1]))-        time.sleep(1)-    after = probe("counting chains after the idempotence probes")-    if True:-        check(chain_pids(after) - chains_before == set(),-              "three swx_start calls during flight started ZERO new chains",-              f"{sorted(chain_pids(after) - chains_before)}")-        check(log_names(after) - logs_before == set(),-              "...and produced ZERO new launcher logs",-              f"{sorted(log_names(after) - logs_before)}")-     # ── 6. ATTACH ──────────────────────────────────────────────────────────     attached_at, sldworks_at = None, None     deadline = time.time() + ATTACH_BUDGET@@ -480,6 +469,53 @@ def main():     check(attached_at is not None, f"COM attached within {ATTACH_BUDGET}s",           f"{attached_at:.1f}s" if attached_at else "never") +    # ── 6b. READY — attached is not ready ──────────────────────────────────+    # MEASURED 2026-10-01: document commands sent 8 s after COM attach crashed+    # SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising. The bridge+    # now reports `solidworks.ready` (StartupProcessCompleted + the performance+    # log) and refuses document verbs until it is true; this gate waits for it,+    # and probes NOTHING before it.+    ready_at = None+    if attached_at is not None:+        deadline = time.time() + READY_BUDGET+        while time.time() < deadline:+            s = verb("swx_status", {}, 40)+            sw = s.get("solidworks", {})+            if sw.get("ready") is True:+                ready_at = time.time() - t0+                print(f"  ready, based on {sw.get('readiness', {}).get('basedOn')}")+                break+            print(f"  ... attached, not ready yet: {json.dumps(sw.get('readiness'))[:200]} "+                  f"{int(time.time() - t0)}s")+            time.sleep(POLL)+    check(ready_at is not None, f"SOLIDWORKS reported READY within {READY_BUDGET}s of the start",+          f"{ready_at:.1f}s" if ready_at else "never")++    # ── 5. IDEMPOTENCE — three calls, only once SOLIDWORKS is READY ─────────+    # (Moved after READY on 2026-10-01: no probe of any kind reaches SOLIDWORKS+    # before it has finished starting. In-flight idempotence of the launch+    # machine itself is pinned by the suite, tests/launch.rs.)+    before = probe("counting chains before the idempotence probes")+    chains_before = chain_pids(before)+    logs_before = log_names(before)+    stages = []+    for i in range(3):+        again = verb("swx_start", {"reason": f"launch-gate: idempotence probe {i + 1}"}, 60)+        stages.append((again.get("stage"), again.get("stillLaunching"), again.get("launchedNow")))+        check(again.get("success") is True and again.get("launchedNow") is not True+              and again.get("stage") == "ready",+              f"swx_start after READY #{i + 1} launched nothing and reports stage ready",+              json.dumps(stages[-1]))+        time.sleep(1)+    after = probe("counting chains after the idempotence probes")+    if True:+        check(chain_pids(after) - chains_before == set(),+              "three swx_start calls after READY started ZERO new chains",+              f"{sorted(chain_pids(after) - chains_before)}")+        check(log_names(after) - logs_before == set(),+              "...and produced ZERO new launcher logs",+              f"{sorted(log_names(after) - logs_before)}")+     # ── 7. NOTHING PRE-EXISTING WAS TOUCHED ────────────────────────────────     final = verb("swx_status", {}, 40).get("launch", {})     end = probe("final census: everything that was running before must still be")@@ -506,7 +542,7 @@ def main():     # ── 8. THE REPORT ──────────────────────────────────────────────────────     print("\nTIMINGS")     for label, at in [("chain appeared", chain_seen_at), ("Run Begin", run_begin_at),-                      ("sldworks.exe", sldworks_at), ("COM attach", attached_at)]:+                      ("sldworks.exe", sldworks_at), ("COM attach", attached_at), ("READY", ready_at)]:         print(f"  {label:<16} {f'{at:6.1f}s' if at is not None else '     --'}")     print(f"\nROUTE     won={final.get('route')}  remembered={final.get('preferredRoute')}")     print(f"          attributed={final.get('attributed')} rootConfirmed={final.get('rootConfirmed')} "@@ -532,7 +568,7 @@ def main():         for f in FAILURES:             print("  - " + f)         return 1-    print("GATE PASSED — one chain, Run Begin, three no-op probes, attach, nothing else touched.")+    print("GATE PASSED — one chain, Run Begin, attach, READY, three no-op probes, nothing else touched.")     return 0  
tools/quit-gate.py+62−10
@@ -76,6 +76,8 @@ import time # (see `verb`); SWX_BRIDGE_URL forces a loopback /command URL instead. The old # default, http://127.0.0.1:24695/command, is dead since adom-bridge 2.1.163. THREAD = "SolidWorks Bridge"+PS_FAST_EMPTY_RETRIES = 3+PS_RETRY_BACKOFF_SECS = 2  # The per-session servers a graceful exit should reap (QUIT-INVESTIGATION §0.2). SESSION_CHILDREN = ("sldProcMon.exe", "sldsurfacing.exe", "sldimport.exe", "swCefSubProc.exe")@@ -111,7 +113,7 @@ class Refused(Exception):     """The human preconditions were not met. Nothing was attempted."""  -def ps(script, reason, timeout=120, _retry=False):+def ps(script, reason, timeout=120, _attempt=0):     """Run PowerShell on Drew's desktop and return stdout.      Every command this file sends: `Get-CimInstance Win32_Process` (a census),@@ -140,14 +142,18 @@ def ps(script, reason, timeout=120, _retry=False):     if payload.get("success") is False:         # MEASURED 2026-09-30 (adom-bridge 2.1.163): shell_execute sometimes fails         # FAST (exit 1, under 3 s, empty stdout AND stderr) and the identical-        # command succeeds 5/5 immediately after. That shape is 'could not ask',-        # not an answer, so it is retried ONCE and the retry is printed. Any other-        # failure shape, or a second failure, is still Unreadable.+        # command succeeds immediately after. MEASURED AGAIN 2026-10-01: it+        # happened TWICE IN A ROW (260 ms each). That shape is 'could not ask',+        # not an answer, so it is retried up to THREE times, 2 s apart, and every+        # retry is printed. Any other failure shape, or a fourth failure, is still+        # Unreadable.         fast_empty = (payload.get("exitCode") == 1 and not (payload.get("stdout") or "")                       and not (payload.get("stderr") or "") and (payload.get("durationMs") or 99999) < 5000)-        if fast_empty and not _retry:-            print("  (shell_execute failed fast with no output; retrying once)")-            return ps(script, reason, timeout, _retry=True)+        if fast_empty and _attempt < PS_FAST_EMPTY_RETRIES:+            print("  (shell_execute failed fast with no output; retry %d of %d in %ds)"+                  % (_attempt + 1, PS_FAST_EMPTY_RETRIES, PS_RETRY_BACKOFF_SECS))+            time.sleep(PS_RETRY_BACKOFF_SECS)+            return ps(script, reason, timeout, _attempt=_attempt + 1)         raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])     out = payload.get("stdout")     if out is None:@@ -691,12 +697,54 @@ def run_ladder(base, max_rung, allow_rm, reason):                      "answer. (Closing documents is not something to ask for: "                      "swx_close_all_safely does it.)" % json.dumps(r["blockingDialog"]))     st = verb("swx_status", {}, 60)-    check(st.get("com", {}).get("running") is not True or gone_at is None,-          "swx_status no longer reports a running SOLIDWORKS",-          json.dumps(st.get("com"))[:200])+    claim_status_not_running(st)     return gone_at is not None  +def claim_status_not_running(st):+    """THE STATUS CLAIM, and only true when it is true.++    It used to read `running is not True or gone_at is None` — so after a+    DECLINED quit (gone_at None) it printed PASS while swx_status said+    `running: true`, twice (2026-09-30 and 2026-10-01). A claim that passes on+    the outcome it exists to catch is not a claim. Now `running` must be+    exactly false; true, null or absent FAILS. `--self-test` pins it with+    canned replies.+    """+    running = st.get("com", {}).get("running")+    return check(running is False,+                 "swx_status no longer reports a running SOLIDWORKS",+                 "com.running=%s %s" % (json.dumps(running), json.dumps(st.get("com"))[:160]))+++def self_test():+    """The gate's own claims, against canned replies — no machine, no bridge.++    A declined quit leaves `com.running: true`; the status claim must FAIL on+    it. A quit leaves `running: false`; it must PASS. An unreadable status+    (no `running`) must FAIL, because unknown is not gone.+    """+    cases = [+        ("declined quit: swx_status still says running:true", {"com": {"running": True, "attached": True}}, False),+        ("unreadable status: no running field", {"com": {"busy": True}}, False),+        ("real quit: running:false", {"com": {"running": False}}, True),+    ]+    bad = []+    for label, reply, want in cases:+        before = len(FAILURES)+        got = claim_status_not_running(reply)+        if got != want or (len(FAILURES) > before) == want:+            bad.append(label)+    del FAILURES[:]+    print("\n" + "=" * 72)+    if bad:+        print("SELF-TEST FAILED — the status claim gave the wrong verdict for: " + "; ".join(bad))+        return 1+    print("SELF-TEST PASSED — the status claim FAILS on a declined quit and an unreadable status, "+          "and PASSES only on running:false.")+    return 0++ # ── step 4: the question that decides whether quitting is offerable ─────────  def relaunch():@@ -742,7 +790,11 @@ def main():     ap.add_argument("--no-relaunch", action="store_true", help="skip step 4")     ap.add_argument("--reason", default="quit gate: measuring the graceful quit ladder "                                         "on an empty, bridge-launched session (Drew approved)")+    ap.add_argument("--self-test", action="store_true",+                    help="check the gate's own claims against canned replies; touches nothing")     a = ap.parse_args()+    if a.self_test:+        return self_test()     if a.rung == "f" and not a.allow_restart_manager:         print("rung (f) needs --allow-restart-manager: the Restart Manager tells applications to "               "save WITHOUT prompting, so it is never taken implicitly.")