← Commit history

0.9.0: bridge data moved out of the install cache (recovery survives updates), closed-in-place records, gates route through ab; quit gate live-proven 2026-09-30

Drew Owens ·5d02ca91bb ·20d ago ·parent f4a220b
56 files changed +14021−346
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.6.9\ No newline at end of file+0.9.0
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.6.9"+version = "0.9.0" dependencies = [  "serde",  "serde_json",
Cargo.toml+4−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.6.9"+version = "0.9.0" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"@@ -57,6 +57,9 @@ windows = { version = "0.62", features = [     # explicit SW_SHOWNORMAL. SHELLEXECUTEINFOW carries an HKEY, so the Registry     # feature is what makes the struct exist at all.     "Win32_UI_Shell",+    # Storage_FileSystem: GetDiskFreeSpaceExW — the sweep refuses to start+    # writing every open document to a volume that cannot hold them.+    "Win32_Storage_FileSystem",     "Win32_System_Registry",     # SystemInformation: GetSystemTimeAsFileTime — the bridge reads its own     # clock through the SAME call the process creation times come from, so
README.md+349−5
@@ -241,9 +241,20 @@ The suite is built so it cannot lie:   child's stdout, orphan cleanup, and a slow verb that must not block   `swx_progress`. -Everything the bridge writes goes under one root-(`%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\`, or `SWX_SCRATCH_DIR`-for tests), pruned at 48h / 300 files, and removed in full by `swx_uninstall`.+Everything the bridge writes goes under one data root,+`%LOCALAPPDATA%\Adom Bridge\solidworks-data\` (or `SWX_SCRATCH_DIR` for tests):+the recovery folders, the action ledger, the ownership registry, launch state,+verb timings and the bridge's own exports. **Never inside ab's install cache**+(`bridges-cache\solidworks\`, where the bridge itself is installed): ab's updater+re-extracts that directory, and until 0.9.0 the data lived there — the 0.8.9+auto-install of 2026-09-18 deleted every recovery folder from the day before.+The first start of 0.9.0 migrates anything still in the old location (copied,+verified byte for byte, originals left in place; see+`migrated-from-legacy.json`), and a sweep refuses outright+(`recovery_inside_install_dir`) if its recovery folder would land under an install+location. Exports are pruned at 48h / 300 files. `swx_uninstall` removes the+bridge's own bookkeeping and exports, and never the recovery folders or the+ledger — a recovery file can be the only copy of work the user had not saved.  `swx_com_eval {"expr":"$sw.RevisionNumber()","reason":"..."}` is the maintainer escape hatch: a late-bound call chain rooted at `$sw` or `$doc`, for proving out@@ -282,7 +293,7 @@ those are different permissions. What replaces the question is a sequence: **nothing closes before it is on disk.**  Every document, the user's or the bridge's, saved or not, is written to-`<bridge cache>\exports\recovery\<yyyymmdd-hhmmss>\` and verified on disk+`%LOCALAPPDATA%\Adom Bridge\solidworks-data\recovery\<yyyymmdd-hhmmss>\` and verified on disk before anything closes. A document that could not be written is *not* closed — closing a document whose only copy is in memory is the one outcome this design exists to prevent, and the response names any such document in `remaining`. A@@ -434,7 +445,340 @@ not.  ## Roadmap -- v0.7.6 (this): **ask the evidence already in hand, and tell the user how to+- v0.9.0 (this): **the user's rescued work no longer lives where an update+  deletes it — SEV-1, measured live 2026-09-30.** ab's install cache for this+  bridge, `bridges-cache\solidworks`, was found CREATED 2026-09-18 09:42 by the+  auto-install of 0.8.9: the updater re-extracts the whole directory, and that+  directory was also this bridge's data root. Every recovery folder from+  2026-09-17 went with it, with the ledger, the ownership registry and the scratch+  documents. Only test documents were in them. The recovery folder exists to hold+  the ONLY copy of work the user had not saved; it cannot live in a directory a+  routine update empties.++  So 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+  there — the recovery folders, the ledger, the ownership and launch registries+  (with their previous generations), timings, settings and exports — copied,+  verified byte for byte (streamed), never over an existing file, the originals+  LEFT where they were, and each file ONCE: `migrated-from-legacy.json` is+  cumulative, so a copy the user deletes stays deleted, while a copy that failed+  verification (and was removed, so no half-copied rescue ever looks whole) is+  retried at the next start. Every copy is made under a temporary name and+  renamed into place only once it verifies, so a start that dies mid-copy — a+  crash, or ab killing a bridge slow to answer `/status` — never leaves a+  truncated rescue under the real name, where never-overwrite would protect it+  forever. The migration runs BEFORE the bridge reads its own state, so the first+  start after the move already owns what the older bridge created. `swx_recover_list` still reads the old location, marks each sweep+  there `atRiskFromUpdate`, and says whether a verified copy exists (`copiedTo`)+  or that folder is the only one; in the data root, a file whose copy has not+  landed is `notCopiedYet` with its `legacyPath` — never "gone" — and a+  half-copied temporary whose original has since disappeared is a `partialCopy`:+  never a rescue, and never hidden. And a sweep REFUSES — `recovery_inside_install_dir`,+  before writing or closing anything — if its recovery folder would land under+  the install directory or ab's install cache, however the path is spelled+  (links, `..`, case, either separator). One limit, stated: the update that+  installs 0.9.0 re-extracts the old directory BEFORE 0.9.0 first runs, so on a+  machine ab updates from 0.8.9 the migration will most likely find nothing. The+  move is what protects every update after this one.++  Also: **a clean, saved document is RECORDED as closed in place.** Its own file+  is its preservation, so the sweep wrote no copy — and the manifest had no entry+  for it, so `swx_recover_list` could not say "your file was closed at its own+  path, untouched" (measured live 2026-09-30 on Drew's+  `R_0603_1608Metric_1.step-1.sldprt`). The sweep now records such a document as+  `closedInPlace` with the original's size and mtime, and each listed sweep+  carries `closedInPlace` entries that MEASURE the file: untouched, changed since,+  or no longer at its path. A pre-0.9.0 record is read too, with its close+  reported as unknown rather than the `false` it wrote.++  And the live gates now reach the bridge the way ab runs it: through+  `adom-bridge <verb>` routing to the bridge ab hosts, since ab 2.1.163 terminates+  a side-loaded dev copy on 24695 within 30 s (`SWX_BRIDGE_URL` forces the old+  loopback route); the quit gate passes `userAuthorized:true`, the standing+  authorization, instead of the write-gate pair. The full quit cycle PASSED LIVE+  2026-09-30 through the hosted bridge (docs/QUIT-INVESTIGATION.md §5.14). A+  release is now a deploy: ab hosts whatever the published manifest names.+  413 tests.+- v0.8.9: **the never-ask rule defaults to governed, like everything else+  here.** 0.8.8's per-file policy still leaked twice: a blockquoted ask in a+  `docs/` RUNBOOK slipped, because a runbook instructs even though it lives under+  docs, and `skills/**/SKILL.md` was never scanned at all. Both are the same+  shape — a rule stated as "these files are governed" is wrong by omission the+  moment somebody adds a file. So it is inverted, matching every other default in+  this bridge (unknown kind takes the strictest floor, an unreadable census is not+  permission, an unverifiable destination is not free): every `.md`, `.rs` and+  `.py` in the repo is scanned and NOTHING is stripped, with a short allowlist of+  files that DESCRIBE — the README and the named reports under `docs/` — where+  quoted and code spans are. A new file is governed until somebody says otherwise.++  Also: a sweep's manifest now records its FAILURES, with whatever structure the+  host gave. A host failure is the case where the folder holds fewer files than+  there were documents, so it is the one thing a reader most needs explained — and+  the record was being deleted precisely then, because nothing had been preserved.+  A timeout, which kills the com host before it can answer, reports its own facts+  rather than a null that cannot be told from having thrown the detail away. 402+  tests.+- v0.8.8: **the fix for the changelog blinded the file an agent follows.**+  0.8.7 taught the never-ask pin to skip quoted text, code spans and blockquotes+  in every markdown file, so a changelog could record the sentences it catches.+  Measured afterwards: in SKILL.md a blockquoted instruction and a backticked one+  both slipped, while the same sentence in plain prose was caught. SKILL.md is not+  documentation about the bridge — it is what an agent LOADS AND FOLLOWS, and+  markdown emphasis there is presentational. A model reading a blockquote does not+  conclude the sentence is an example.++  So the policy is per FILE, never per syntax: README.md and docs/** describe, and+  their quoted spans are stripped; SKILL.md strips NOTHING. Both of the adversary's+  SKILL.md sentences are pinned as must-catch, and the changelog quote above stays+  allowed.++  Three guards were also shipping unpinned, each proven by a surviving mutation:+  `write_manifest`'s `verified` hard-coded to true, `host_failure_entry` dropping+  `hostError`, and `acknowledge_only` loosened to one-or-more buttons. The first+  two are the pass-29 and pass-30 fixes — the manifest is the permanent record of+  WHY a document was not preserved — and nothing could tell, because every sweep+  test preserved everything. A refused-document sweep now pins both. 400 tests.+- v0.8.7: **the changelog broke the test that reads it.** The 0.8.6 entry+  below quotes both bypass sentences as examples of what the never-ask pin must+  catch — and the pin read them as violations, so master was RED on a clean+  checkout. I measured the suite green and then wrote that paragraph, which is+  the whole lesson: a documentation edit after the last test run is an untested+  change.++  The fix is not an exemption phrase and is certainly not editing the changelog+  until it stops saying what was fixed. The pin now understands a CATEGORY —+  quoting rather than instructing — and skips markdown blockquotes, fenced and+  inline code spans, and quoted strings, so example violations live in quotes and+  in the cases table. Both are pinned: a quoted example does not trip it, and the+  same sentence unquoted does.++  The rule also turned out to be one-directional: `"Close the documents (ask the+  user)."` slipped, because an imperative may lead with its object. It is now+  order-independent within a statement, while the rule stating itself is still+  allowed, and all five of the adversary's sentences are rows in the table. And+  the drain gate's twin opt-in — the one place that tool overrides the bridge's+  ownership answer — is now scoped to the SOLIDWORKS session recorded in the run+  manifest, not to the path alone: a twin from another session has its file+  removed but is never force-closed. 396 tests.+- v0.8.6: **the gate's cleanup can find the runs that died, and cannot be+  pointed at anything else.** 0.8.5 gave every run a unique scratch ROOT, which+  fixed "earlier runs look foreign" by making them invisible: the manifest scan+  looked inside the fresh directory, found only its own, and adopted nothing — so+  a SIGKILL, a crash or a reboot, exactly what `atexit` cannot cover, left a+  directory behind per occurrence with no run that would ever collect it. There is+  now one fixed parent with a per-run subdirectory under it, and the scan reads+  the parent.++  That made a latent hazard reachable, so both landed together: cleanup asked for+  `allowUserWrites` + `confirm` on EVERY path a manifest named, and a manifest is+  just a file on disk. Containment now gates the OPT-IN — the twin, the only+  document that needs it, must lie under the directory this gate owns — while the+  bridge-created documents are closed in the DEFAULT posture, where the bridge's+  own ownership check is the proof. Anything outside is refused, named, and raises+  the exit code.++  And the never-ask pin stopped being a pile of phrases. It had grown eight+  exemptions so it would pass over this project's own commentary, and two were+  working bypasses — *"The sweep passed. Ask the user to close the remaining+  documents."* and *"After the restart, ask the user to close the leftover+  documents."* both slipped through. The rule is now positional (the close must+  follow the ask, inside the same request) and pinned by a table of example+  sentences, carrying both bypasses as must-catch cases. 393 tests.+- v0.8.5: **a refusal that asks instead of instructing, and four smaller+  honesty fixes.** `interconnect_blocks_import` told its reader to *"Turn it off+  in SOLIDWORKS: Tools > Options > …"* — a runbook, and an agent holding a desktop+  bridge can execute a runbook. It now says to ASK the user, and a test fails if+  any text on that code becomes imperative about changing a setting. `verified`+  no longer survives a manifest entry that cannot be TIED to the bytes on disk: a+  missing or null `recoveryBytes` was read as agreement, so a verdict about one+  file was handed to another — and `verified:true` means the document was closed,+  so that file is the only copy. The manifest's VERSION is checked, not just the+  shape of `files`. A coded refusal no longer carries the generic "if a dialog is+  up, dismiss it and retry" hint, which was wrong for every one of them. The+  recovery folder is claimed with `create_dir` — the creation IS the claim, where+  check-then-create raced. `GATE PARTIAL` exits 3, not 0, because a caller reading+  the status could not tell an untested half from a pass.++  And the live measurement from the drain gate is recorded: SOLIDWORKS REFUSED a+  second open of a same-titled saved document with `errors=65536`+  (`swFileWithSameTitleAlreadyOpen`), so **a title collision is impossible for+  saved documents** — only pathless ones can collide, which is exactly the state+  identity-at-birth removes — recorded beside the guard it justifies, in+  `com.rs`, dated, with the invocation that produced it.++  The drain gate also stopped leaving its own mess behind. Cleanup ran only on+  the success path, so a refusal — which is EVERY run on that machine while the+  License Manager dialog is up — left the gate's documents open in Drew's session+  and its files on disk; and the leftover check called the gate's own copies+  foreign, which is what stopped it touching them. It now keeps a per-run+  manifest, written before each creation, runs cleanup from a `finally` AND an+  `atexit`, and uses a scratch directory unique per run. Verified locally: a+  passing run and a FAILING run both end with zero documents open and zero+  directories left. 388 tests.+- v0.8.4: **the gate stopped asking Drew to close things, and a dialog now+  says what it says.** `tools/quit-gate.py` refused a live run with *"Ask Drew to+  close them himself"* — the exact sentence Drew's standing directive exists to+  delete, still sitting in a tool a week after the sweep that makes it+  unnecessary shipped and ran. The gate now runs `swx_close_all_safely` (which+  preserves every document to a verified recovery folder before closing) and+  refuses only if the SWEEP refuses, saying which of its reasons applied. Every+  other "ask the user to close" in the tree is gone, and a test now fails if one+  comes back — a directive that lives only in the code it prompted gets violated+  by everything around it.++  And the blocking-dialog report carries the dialog's own words. Live, the sweep+  stopped correctly on `{"kind":"license","title":"License Manager"}` and that was+  all a caller got; the window actually said *"Failed to check 1 license(s) …+  Unable to connect to server. The session will end after the next 5 unsuccessful+  attempts"* with one button, OK. `stoppedByDialog` now carries `text`, `buttons`,+  `contentsReadable` and `acknowledgeOnly` — read-only, `null` when unreadable,+  and **`neverAnswer` stays true regardless**: whether this bridge may ever press+  an OK-only informational dialog is a question for Drew, not an inference from a+  control tree. The measured fact: that dialog appears when the 3DEXPERIENCE+  licence heartbeat fails (it coincided with the desktop dropping off the relay),+  and both the sweep and the quit are blocked while it is up. 376 tests.+- v0.8.3: **the gate's own bugs, and a way to have caught them here.**+  `tools/drain-gate.py --fixture new` ran live and the registry drain PASSED —+  `swx_reset_workspace` closed exactly the two documents the bridge had made and+  left every pre-existing one open. The three reported failures were all the+  gate's: it asked for `keepUnsaved:true` and then failed the bridge for handing+  back a pathless document; it saved the twin to a path with no extension and+  reported the resulting FORMAT refusal as a write-gate bug; and it tried to close+  a twin that had never been created, printing an empty title. All four are fixed,+  and the classification rule is now explicit — **classify by errorCode, never by+  "it failed"**: a gate that cannot tell its own bug from the bridge's is worse+  than no gate.+  The real lesson is that none of it could be reproduced without the desktop, so+  the gate gained `--local URL`: it can now be run end to end against a mock+  bridge on this machine. It was, and all five claims pass. 372 tests.+- v0.8.2: **the import refuses honestly, naming the setting.** The matrix+  is measured, and the answer is that on this edition with 3D Interconnect ON+  there is no programmatic neutral-file import route at all — LoadFile4 returns+  nothing whether it is handed `Nothing` or a real `GetImportFileData` object,+  LoadFile2 returns false, OpenDoc6 answers "requires repair" for every option+  value tried. The imports that worked from 09-05 to 09-11 must have run with the+  toggle off. So `auto` makes ONE LoadFile4 attempt — a future edition that+  accepts it will simply work — 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**. Reporting a translator failure+  instead would send the reader hunting a missing translator that is not the+  problem. `tools/drain-gate.py --fixture new` proves the same ownership and+  same-title collision properties with `swx_new_document`, since the STEP fixture+  cannot be created on that machine at all — and it states plainly that+  `swx_close_import_temps` is import-temp-BY-PATH by definition, so the registry+  drain is the right verb there. 366 tests.+- v0.8.1: **a measurement surface, because no import route is known to+  work — and two honesty fixes in the rescue record.** 0.8.0 claimed 3D+  Interconnect meant "OpenDoc6 with swDocPART"; live, that returns no document+  with `errors = 2097152` (`swFileRequiresRepairError`) on two real STEP files.+  That claim was recalled, not measured, and it is now marked as what it is. What+  IS measured: toggle 691 is TRUE, `LoadFile4` executes and returns nothing+  (`errors = 1`), `LoadFile2` returns false (still unexplained). PowerShell cannot+  try alternatives — SOLIDWORKS Connected does not expose the type info its COM+  adapter needs, so every member call fails `TYPE_E_ELEMENTNOTFOUND` — which makes+  this bridge the only instrument. So `swx_import` takes `route`, `openOptions`,+  `docType`, `withImportData` and `argString`, echoes the exact `invocation` on+  every reply, and refuses an unknown route rather than quietly measuring+  something else. It is inside the verb that already owns and registers what it+  opens, not a new escape hatch.++  Also: a manifest verdict no longer outlives the bytes it was about — if the file+  changed since the sweep, the listing says UNKNOWN instead of repeating a verdict+  about bytes that are gone. Host failures inside the sweep keep their structured+  COM detail, which since 0.7.8 is the permanent record of why a document was not+  preserved. A throwing import re-counts and TAKES OWNERSHIP of a document that+  appeared anyway, instead of leaving it open and untracked. And two sweeps in the+  same second now get separate folders: they used to share one, so the second+  sweep's manifest overwrote the first's — a real reporting defect, and the+  mechanism behind an intermittent test failure that was fixed rather than+  retried. 365 tests.+- v0.8.0: **3D Interconnect — the import had been taking the wrong road+  all along, and the user's setting decides which road exists.** With 0.7.9's+  encoding fix, `LoadFile4` finally executed on the live install and still+  returned no document, `errors = 1` (`swGenericError`). The reason was measured+  rather than guessed: `GetUserPreferenceToggle(691)`+  (`swMultiCAD_Enable3DInterconnect`) is **TRUE**. Under 3D Interconnect+  SOLIDWORKS does not translate a neutral file at all — it OPENS a STEP as a part+  holding a linked feature that points back at the source. So `swx_import` now+  reads that preference, reports it, and routes: `OpenDoc6(path, swDocPART, …)`+  when it is on, `LoadFile4` when it is off. The reply says `interconnect`,+  `linked` and `sourcePath`, because a caller told only "imported" would be wrong+  about what deleting the source file does.++  **The preference is never written** — not set, and not flipped-and-restored: a+  crash between those two halves would leave the user's SOLIDWORKS configured+  differently than they left it. `dissolve:true` is offered and REFUSED+  (`dissolve_not_measured`) until the link-breaking call is verified on this+  install, with the refusal naming what to measure and the safe way round it;+  guessing an API that writes to a document is how pass 23's format claim would+  have gone if it had been a write. Import Diagnostics auto-run is also ON+  (toggle 291), so a faulty STEP raises a modal dialog mid-import: that is now+  classified, reported as `stoppedByDialog`, and never answered — the choice it+  offers changes the geometry the user gets. 360 tests.+- v0.7.9: **"Nothing" is not "omitted", and it had never worked.** With+  0.7.8's structured failure finally reaching the caller, the live import answered+  `Type mismatch. (0x80020005) (argument index 1)`. `rgvarg` is packed reversed,+  so that argument is the THIRD parameter of+  `LoadFile4(path, "r", importData, byref errors)` — `importData` — which this+  bridge had passed as `VT_EMPTY` since v0.2.0. SOLIDWORKS wants an object there+  and will take a null one; it will not take "no value at all". The late-bound+  spelling of VBA's `Nothing` is a `VT_DISPATCH` variant with a NULL `pdispVal`,+  which is now what `LoadFile4` gets — and what `swx_com_eval`'s `null` literal+  builds, since its documented use is `SelectByID2`'s callout, an `[in]` object+  parameter of exactly the same kind. There is still exactly ONE `LoadFile4` call:+  no silent retry under the old encoding, because a guess nobody can see is worse+  than a failure somebody can read.++  The consequence worth stating plainly: **`LoadFile4` had probably never worked+  live**, and the imports of 09-05 to 09-11 went through the `LoadFile2` fallback+  — which today returns false, unexplained, and is the next thing to measure. None+  of this was findable until the HRESULT stopped being discarded one frame above+  where it was made. 353 tests.+- v0.7.8: **the sweep writes down what it decided — and the first+  autonomous close happened.** LIVE, 2026-09-17: `swx_close_all_safely` took four+  documents to a recovery folder and closed every one of them, count 4 to 0,+  nobody asked to close anything; then `swx_stop` quit SOLIDWORKS on rung (a).+  `ExitApp` is NOT declined at zero documents on this edition — the long-held+  belief was a save-prompt stall, so the blocker was never the quit, it was the+  documents. Also measured: `SaveAs3` to a neutral `.sldrecover` name answers+  "format not supported", so an unclassifiable document is now named `.SLDPRT` up+  front with `namingFallback:"unknownKindNamedAsPart"` recorded and the+  unknown-kind verification unchanged — the name is a label SOLIDWORKS forced, the+  floor is the check. The release's own work: every sweep now writes a+  `manifest.json` beside the files it wrote, rewritten atomically after each save+  and each close, carrying what no listing can re-derive — the original document's+  size, the kind `GetType` reported, and whether the extension was a guess.+  `swx_recover_list` reports the sweep's own verdicts from it (`source:"manifest"`)+  and re-verifies only where no record exists (`source:"reverified"`), where a+  file that merely looks right is now reported as UNKNOWN rather than verified: a+  weaker check does not produce false negatives, it produces false positives, and+  `verified:true` for a file the sweep refused is read by the person whose sweep+  just failed.++  Also in 0.7.8: **a failed import now shows the translator's own answer.** Live,+  every STEP import began failing with `LoadFile4` raising a COM exception, and+  the caller saw only *"SOLIDWORKS could not import … (LoadFile4 opened nothing)"*+  with the detail *"SOLIDWORKS rejected 'LoadFile4'"* — the HRESULT, the EXCEPINFO+  source and description and the failing argument index were already computed and+  then dropped. Every COM failure now carries them structured, and a failed import+  reports `hostError.loadFile4` (message, detail, code, HRESULT) plus what+  `LoadFile2` actually answered, so "refused" and "threw" are distinguishable.+  `swx_com_eval` stays read-only. 351 tests.+- v0.7.7: **the listing can tell a rescue from a husk.** Files land in a+  sweep folder whether or not the sweep vouched for them — a refused save leaves+  one, a save that returns success can write something that is not a document, a+  full disk leaves a truncation — and in every one of those cases the DOCUMENT WAS+  LEFT OPEN, so the work is still in SOLIDWORKS rather than in the folder.+  `swx_recover_list` called all of them rescues, with sizes and instructions for+  opening them, under a headline saying every document closed was written here+  first. It now re-runs the same verdict the sweep used — it is a pure function of+  the file — and reports `verified` with the sweep's own reason per file. An+  unverified file is still listed and never deleted (those bytes are the only ones+  there are) but it is never called a rescue and never offered as something to+  open; its entry says the document was left open. The hint leads with+  "files marked `verified:false` are NOT complete documents". 344 tests.+- v0.7.6: **ask the evidence already in hand, and tell the user how to   reopen what was rescued.** When `GetType` fails, the document's OWN PATH is now   consulted before its title — the path is the user's file as SOLIDWORKS named it,   while a title often has no extension at all (Explorer hides known types by
SKILL.md+45−3
@@ -14,6 +14,28 @@ adom-bridge --ai-thread "<your thread>" swx_status '{}' adom-bridge --ai-thread "<your thread>" swx_bom '{"path":"C:\\designs\\gearbox.sldasm","format":"csv"}' ``` +## Importing: 3D Interconnect decides what you get++- **If `swx_import` fails with `interconnect_blocks_import`, that is the whole+  answer: nothing imports while 3D Interconnect is on.** Tell the user to uncheck+  Tools > Options > System Options > Import > "Enable 3D Interconnect" and try+  again. Do NOT hunt for a missing translator, and do not ask the bridge to change+  the setting — it will not, ever, not even temporarily.+- **`swx_import` reports `interconnect`, `linked` and `sourcePath`. Read them.**+  With 3D Interconnect ON — the default, and TRUE on this install — a STEP is not+  translated: it is OPENED as a part holding a LINKED feature pointing at the+  source file. The part is NOT self-contained, and moving or deleting that file+  changes it. Tell the user that when it matters.+- **The bridge never changes that preference**, not even temporarily. If they want+  an unlinked import, they turn 3D Interconnect off in Options and import again —+  the bridge will route through the translator and say so.+- **`dissolve:true` is refused** (`dissolve_not_measured`) until the link-breaking+  API is verified on this install. Do not work around it; the refusal says what+  to measure.+- **A modal Import Diagnostics dialog can stop an import** (auto-run is ON). The+  reply says `stoppedByDialog`; show the user a screenshot and let them answer.+  Never answer it — it decides whether faulty faces get healed.+ ## Closing and quitting: never ask the user to do it  - **The bridge never asks you to close.** `swx_shutdown {reason}` saves every open@@ -23,7 +45,7 @@ adom-bridge --ai-thread "<your thread>" swx_bom '{"path":"C:\\designs\\gearbox.s   them. - **The safety property is a SEQUENCE, not a question: nothing closes before it is   on disk.** Every document — theirs or the bridge's, saved or not — is written to-  `<bridge cache>\exports\recovery\<timestamp>\` and verified there before+  `%LOCALAPPDATA%\Adom Bridge\solidworks-data\recovery\<timestamp>\` and verified there before   anything is closed. A document that could NOT be written is not closed, and the   response names it in `remaining`. - **The user's own files are never written over.** A document they had saved and@@ -64,11 +86,31 @@ adom-bridge --ai-thread "<your thread>" swx_bom '{"path":"C:\\designs\\gearbox.s - **`swx_close_all_safely {reason}`** is the same sweep without the quit, for when   the user wants an empty session rather than no SOLIDWORKS. - **`swx_recover_list`** shows what every sweep preserved and where, newest first.-  Tell the user this path when you close their work. FOLLOW EACH FILE'S OWN-  `howToOpen`: most say `swx_open`, but a file named `.sldrecover` is the rescue of+  Tell the user this path when you close their work. **CHECK `verified` ON EVERY+  FILE** — and note `source`: `"manifest"` is the sweep's own verdict, `"reverified"`+  is this bridge re-checking a folder that left no record, where `verified:null`+  means UNKNOWN rather than good. `verified:false` means the sweep refused to close against those bytes and+  the document was left OPEN — that work is still in SOLIDWORKS, and telling the+  user it was rescued would be false. Then FOLLOW EACH FILE'S OWN `howToOpen`: most say `swx_open`, but a file named `.sldrecover` is the rescue of   a document whose kind could not be established, and SOLIDWORKS will not open it   until it is renamed to `.SLDPRT`, `.SLDASM` or `.SLDDRW`. Never tell the user to   `swx_open` one of those.+- **A clean, saved document gets no copy — and is still listed.** Its own file is+  its preservation, so the sweep closes it at its own path and never writes to+  that file. Each sweep's `closedInPlace` lists those documents with+  `originalPath`, `closed`, `present` and `untouched` (the file measured against+  what the sweep recorded). When the user asks where such a file went, answer from+  that entry: "closed at its own path, untouched" is only true when `untouched` is+  `true`; `false` means something saved it afterwards, and `present:false` means it+  is no longer at that path — say which.+- **A sweep listed with `atRiskFromUpdate:true`** was written by a bridge older+  than 0.9.0 into ab's install cache, which the next update deletes. If its+  `copiedTo` is set, use that copy; if it is null, that folder is the only copy,+  and the user should hear that before anything updates. In the data root, a+  file marked `notCopiedYet` exists only at its `legacyPath` (same warning), and+  one marked `partialCopy` is an interrupted copy — incomplete, never a rescue. A sweep refused with+  `recovery_inside_install_dir` wrote and closed NOTHING — the data root was+  pointed inside an install location; it is not a disk problem. - **`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.
bridge.json+43−40
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.6.9",+  "version": "0.9.0",   "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",@@ -20,15 +20,44 @@     "swx_"   ],   "verbs": [+    "swx_action_ledger",+    "swx_activate_document",+    "swx_add_component",     "swx_bom",+    "swx_check_recovery",+    "swx_close_all_documents",+    "swx_close_all_safely",     "swx_close_doc",+    "swx_close_import_temps",+    "swx_close_window",+    "swx_cloud_status",     "swx_com_eval",     "swx_describe",+    "swx_design_info",+    "swx_dismiss_blocking_dialogs",     "swx_docs",+    "swx_document_info",     "swx_export",+    "swx_export_3mf",+    "swx_export_dwg",+    "swx_export_dxf",+    "swx_export_glb",+    "swx_export_iges",+    "swx_export_parasolid",+    "swx_export_pdf",+    "swx_export_sat",+    "swx_export_step",+    "swx_export_stl",     "swx_find_files",+    "swx_get_app_state",+    "swx_get_parameters",+    "swx_get_preferences",+    "swx_import",+    "swx_inspect_bodies",     "swx_interference",+    "swx_kill",     "swx_mass_properties",+    "swx_new_document",     "swx_open",     "swx_pack_and_go",     "swx_progress",@@ -36,53 +65,27 @@     "swx_props_set",     "swx_readiness",     "swx_rebuild_check",+    "swx_recover_list",+    "swx_reset_workspace",+    "swx_run_macro",+    "swx_run_script",     "swx_save",+    "swx_save_as",     "swx_screenshot",+    "swx_set_driving_badge",+    "swx_set_parameter",+    "swx_set_preference",+    "swx_show_assembly",+    "swx_show_drawing",+    "swx_show_part",+    "swx_shutdown",     "swx_start",     "swx_state",     "swx_status",     "swx_stop",     "swx_uninstall",-    "swx_new_document",-    "swx_import",-    "swx_save_as",-    "swx_get_app_state",-    "swx_document_info",-    "swx_activate_document",-    "swx_close_all_documents",-    "swx_reset_workspace",-    "swx_close_import_temps",-    "swx_kill",-    "swx_window_info",-    "swx_dismiss_blocking_dialogs",-    "swx_close_window",-    "swx_check_recovery",-    "swx_action_ledger",     "swx_verb_times",-    "swx_set_driving_badge",-    "swx_design_info",-    "swx_get_parameters",-    "swx_set_parameter",-    "swx_inspect_bodies",-    "swx_run_macro",-    "swx_run_script",-    "swx_add_component",-    "swx_get_preferences",-    "swx_set_preference",-    "swx_cloud_status",-    "swx_show_part",-    "swx_show_assembly",-    "swx_show_drawing",-    "swx_export_step",-    "swx_export_iges",-    "swx_export_stl",-    "swx_export_3mf",-    "swx_export_glb",-    "swx_export_sat",-    "swx_export_parasolid",-    "swx_export_pdf",-    "swx_export_dxf",-    "swx_export_dwg"+    "swx_window_info"   ],   "statusVerb": "swx_status",   "risk": "exec",
docs/ECOSYSTEM-TABLE.md+21−11
@@ -67,7 +67,7 @@ templates**, alisamsam **22**. | # | Capability | Fusion bridge | **swx v0.3** | SolidPilot **(AGPL-3.0!)** | vespo92 (MIT) | bartels (MIT) | Schildkraut (MIT) | alisamsam (MIT) | |---:|---|---|---|---|---|---|---|---| | 1 | Session lifecycle — attach, stop, kill | ✓ | **✓ live** attach · stop: **unproven** — the one observed `ExitApp` "decline" had a dirty Part1 open (save-prompt stall, not a decline); no stop event at 0 docs is in the ledger. v1 also holds a COM ref while checking and gates on unsaved-count not open-count (docs/QUIT-INVESTIGATION.md); graceful-quit ladder = 0.5.0 work · kill: never (policy) | ✓ attach | ? | ✓ attach | ? | ✓ attach |-| 2 | Launch the CAD application (and install it) | ✓ launch + silent install | **✓ LIVE-PROVEN 2026-09-11 — attributable.** `shellExecute` (ShellExecuteExW + SEE_MASK_NOCLOSEPROCESS) handed back a real handle for the Platform .lnk (CATSTART pid 131272) and that chain reached `Run Begin`; the old cmd-route stall is gone. Same run found the bridge killing its OWN chain while the user was at the 3DEXPERIENCE ID sign-in — a native WebView2 window with no UIA controls — because the sldworks deadline fired on a wait that was not a stall. **Fixed 0.6.0**: the sign-in window is recognised by class and owner pid, every deadline PAUSES while it is up, nothing is killed or escalated, the user is toasted, and a session that attaches after the sign-in is re-attributed (`attachedAfterSignIn`) instead of being recorded as somebody else's | ✓ claims COM launch (refused on Connected) | ✗ | ✗ needs SW already open | ? | ✗ needs running instance |+| 2 | Launch the CAD application (and install it) | ✓ launch + silent install | **✓ LIVE-PROVEN 2026-09-11 — attributable.** `shellExecute` (ShellExecuteExW + SEE_MASK_NOCLOSEPROCESS) handed back a real handle for the Platform .lnk (CATSTART pid 131272) and that chain reached `Run Begin`; the old cmd-route stall is gone. Same run found the bridge killing its OWN chain while the user was at the 3DEXPERIENCE ID sign-in — a native WebView2 window with no UIA controls — because the sldworks deadline fired on a wait that was not a stall. **Fixed 0.6.0**: the sign-in window is recognised by class and owner pid, every deadline PAUSES while it is up, nothing is killed or escalated, the user is toasted, and a session that attaches after the sign-in is re-attributed (`attachedAfterSignIn`) instead of being recorded as somebody else's. **LIVE AGAIN 2026-09-30, through the bridge ab hosts** (adom-bridge 2.1.163 auto-installed the released 0.8.9): `tools/launch-gate.py` passed every launch claim — `shellExecute`, attributed, `Run Begin` 28.8 s, the helper sign-in step passed without a sign-in, COM attached 139.8 s | ✓ claims COM launch (refused on Connected) | ✗ | ✗ needs SW already open | ? | ✗ needs running instance | | 3 | Requires an in-app add-in | yes (3 verbs to manage it) | **no — out-of-process COM** | no (C# COM server) | no | no | no | no | | 4 | Open a local file · disk discovery | ✓ open · ✗ discovery (cloud-native) | **✓ live** open · **✓** `find_files` | ✓ open · ✗ | ✓ (untested) | ✓ | ✓ | ✓ open · ✗ | | 5 | Create a new document | ✗ (docs arrive from cloud/import) | **✓ live** | ✓ part + assembly | ✓ (untested) | ✓ | ✓ | ✓ part + assembly |@@ -101,7 +101,7 @@ templates**, alisamsam **22**. | 33 | **Action ledger + measured per-verb timings** | ✓ ledger, verb times, live feed, alerts | **✓ ledger + timings recorded live** | ✗ (dev log only) | ✗ | ✗ logging only | partial ("enhanced logging") | ✗ | | 34 | **Deny-by-default write gate** | ✗ no gate, no confirm/reason in its catalog | **✓ live — unique. Two independent layers, 12 adversarial rounds, held in all 3 live runs.** HONESTY NOTE: pass 12 (2026-09-09) found that an un-stat-able destination read as FREE — `Path::exists()` is false both for "nothing is there" and for "could not look" (a parent it cannot traverse, a UNC share it has no token for, a device not ready, an over-length path), and the com host does not share a filesystem view with SOLIDWORKS. Fixed in **0.5.9**: `symlink_metadata` gives three answers and an unreadable one REFUSES (`destination_unverifiable`) in BOTH layers, tested in both | ✗ | ✗ | partial ("adapter safety wrappers") | partial (macro exec needs permission) | ✗ | | 35 | Host-ecosystem integration (Adom Bridge, Activity Log, skill) | ✓ native | **✓ native** | ✗ MCP clients only | ✗ | ✗ | ✗ | ✗ |-| 36 | **Ownership persists across bridge/host restarts** (bridge-created docs stay closable without opt-in) | ✗ (no ownership model) | **✓ live 2026-09-08** — `owned-docs.json` keyed to the SOLIDWORKS process; proven create → host kill → close in default posture | ✗ | ✗ | ✗ | ✗ | ✗ |+| 36 | **Ownership persists across bridge/host restarts** (bridge-created docs stay closable without opt-in) | ✗ (no ownership model) | **✓ live 2026-09-08** — `owned-docs.json` keyed to the SOLIDWORKS process; proven create → host kill → close in default posture. **And across UPDATES since 0.9.0**: the registry lived in ab's install cache until then, so the 0.8.9 auto-install wiped it (row 49); it now lives in the data root, and the first start after the move restores what an older bridge recorded (pinned: the migration runs before the registry is read) | ✗ | ✗ | ✗ | ✗ | ✗ | | 37 | **Honest UNKNOWN instead of a fabricated empty/zero** (doc list, counts, dialogs, interference) | **✓ LIVE 2026-09-09** — on a real 2144-document session `swx_docs` answers in 3.4 s with `count:2144, truncated:true`, a 10-doc sample (8 import temps) — after 0.5.4 wedged 30 s on the same session (fixed 0.5.5: bounded GetFirstDocument/GetNext walk, never GetDocuments above the cap; simulator now charges per-doc latency) | **⚠ 0.4.x-0.5.5, mock-proven; the ENUMERATION half FAILED LIVE** — `docs_enumeration_failed`, `Option` counts, `unsaved_unknown`, `count:null` semantics in hints; 29 adversary tests. **Live 2026-09-09: `swx_docs` still timed out at 30s and the com host was killed on a 2144-document session** — the degrade path called `GetDocuments` (one call, every handle) and then `GetPathName` on all 2144. `GetDocumentCount` answered in 0.7s. The mock's enumeration was instant, so this row was mock-proven and false live. **Fixed in 0.5.5** (walk `GetFirstDocument`/`GetNext` for the sample only, time-boxed; the drain walks a bounded window too) with the live session held in the suite via a per-document latency model — **re-proof on the live 2144-doc session pending** | ✗ | ✗ | ✗ | ✗ | ✗ | | 38 | **Hung / exhausted host detection, and BUSY vs DEAD** (Not Responding + real COM timeout; NewDocument-returns-nothing; CPU/memory delta over a sample) | ✗ | **✓ 0.5.1** evidence-based `host_app_hung` / `host_app_exhausted` + `verdict:"busy"\|"dead"\|"unknown"` from `cpuActiveSeconds`/`memoryDeltaMB` — measured live: 17.9 GB not-responding for ~5 min at ~70% of a core and it RECOVERED, so "not responding" now advises WAIT, not "end the task". One reading never says dead | ✗ | ✗ | ✗ | ✗ | ✗ | | 39 | **Stalled-launcher recovery with attribution law** (kill only a chain proven ours by handle; never daemons, never sldworks) | ✗ | **✓ shipped 0.4.4**, 4 adversary rounds, **live-proven 2026-09-11** — the handle world is confirmed on this machine, and the one live defect was the opposite of an over-eager kill law: it killed a chain that was correctly WAITING (sign-in), fixed in 0.6.0. No daemon and no sldworks.exe has ever been touched | ✗ | ✗ | ✗ | ✗ | ✗ |@@ -109,11 +109,15 @@ 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) | ✗ | **⚠ 0.5.3 mock-proven, UNPROVEN LIVE** — 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 has not been run | ✗ | ✗ | ✗ | ✗ | ✗ |+| 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 | ✗ | ✗ | ✗ | ✗ | ✗ | -| 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) | ✗ | **⚠ 0.7.6 mock-proven, first live close PENDING** — `swx_shutdown {reason}` / `swx_close_all_safely {reason}` save every open document into `…\exports\recovery\<timestamp>\`, 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` gives every 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; 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). **No live run yet** — first live use is `swx_close_all_safely` alone | ✗ | ✗ | ✗ | ✗ | ✗ |+| 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 | ✗ | ✗ | ✗ | ✗ | ✗ |+| 49 | **The user's rescued work survives the bridge's own updates** (recovery never lives where an update deletes it) | ✗ | **✓ 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 Bridge\solidworks-data\recovery\<timestamp>\` (until 0.9.0, `…\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 | ✗ | ✗ | ✗ | ✗ | ✗ | -*(45 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 — the bar is "significantly more powerful than the MCP servers", so every robustness capability gets a row with its evidence level, not a slogan.)*+*(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.)*  ## Per-competitor notes @@ -239,12 +243,18 @@ value-per-effort:  ## What is still unproven, and where -Row 44 is the newest and the loudest: **no rung of the quit ladder has ever run against a real-SOLIDWORKS.** `tools/quit-gate.py --baseline` is read-only and can be run any time; the rest ends-Drew's session and needs his go-ahead on an empty, bridge-launched one. Five of the six MCP-servers surveyed never attempt a quit at all, and the sixth reaches for a kill — so this is the-row where being *unproven* still puts us ahead of the field, and where being *wrong* would cost-the user a 1-3 minute relaunch that can fail. That asymmetry is why it ships pinned at rung (a).+Row 44 is no longer unproven: the quit ladder's rung (a) has quit a real SOLIDWORKS twice, and on+2026-09-30 `tools/quit-gate.py` ran its full cycle — quit, measure, relaunch — through the bridge ab+hosts. Rungs (b)–(f) have still never run live, and nothing has needed them: the ladder ships pinned+at rung (a), because being *wrong* on a later rung would cost the user a 1-3 minute relaunch that can+fail. Five of the six MCP servers surveyed never attempt a quit at all, and the sixth reaches for a+kill.++The newest open question is about testing rather than capability: adom-bridge 2.1.163 terminates a+second `swx-bridge.exe` within 30 s, so the side-loaded dev copy the gates used is gone, and which+dev route ab tolerates (a prerelease install, or a renamed exe reached with `SWX_BRIDGE_URL`) has not+been measured. Until it is, remember that ab hosts whatever the published manifest names — **a+release IS a deploy to every machine**.  Nothing above changes the standing recommendation: **run `tools/live-smoke.py`** once SOLIDWORKS is back up, started from the 3DEXPERIENCE Platform. It is what converts the ⚠ mock cells in column B into
docs/IMPORT-LEAK.md+4−1
@@ -237,7 +237,10 @@ documents.   happened, why nothing was closed (the resolved temp directory is the whole   proof, and the close does not prompt), and what to do: read the sample path and   restart the bridge with `SWX_TEMP_ROOT` set to the directory above the-  `swx<pid>` folder, or ask the user to close them in SOLIDWORKS.+  `swx<pid>` folder, or close them with `swx_close_all_safely`, which writes every+  document to a recovery folder and verifies it before closing. **Never ask the+  user to close documents** — Drew, 2026-09-11: *"I despise every time you ask for+  help closing."* * **two candidate roots, with a documented precedence.** `SWX_TEMP_ROOT`, when   set, is the whole set. Otherwise the set is this process's `%TEMP%` (`TEMP`,   `TMP`, `TMPDIR`) **and** the interactive user's `HKCU\Environment\TEMP`
docs/LAUNCH-RESILIENCE.md+54−4
@@ -245,7 +245,7 @@ never rendered as a healthy value. | Code | Raised when | The hint says | |---|---|---| | `host_app_hung` | `IsHungAppWindow` is TRUE **and** a COM call timed out | only the user can end it, and why this bridge will not |-| `host_app_exhausted` | `NewDocument` returned nothing, **or** private memory > 12 GB, **or** > 40 documents open | close the bridge's own documents; then ask the user to restart; **do not retry in a loop** |+| `host_app_exhausted` | `NewDocument` returned nothing, **or** private memory > 12 GB, **or** > 40 documents open | close the bridge's own documents (`swx_close_all_safely` preserves them first); then a RESTART of SOLIDWORKS is the user's call; **do not retry in a loop** | | `host_app_com_unreachable` | `sldworks.exe` is running, COM will not attach, and it is neither hung nor exhausted | probably a sign-in window; ask the user to look at their screen |  Both hung signals are required, and an unknown is never a yes: a not-responding@@ -818,9 +818,16 @@ no write outside `$env:TEMP`).  ## 9. STILL UNPROVEN until the live gate runs -Nothing below has touched a real SOLIDWORKS. `tools/launch-gate.py` is written-to settle every line of it, and **must only be run when Drew says SOLIDWORKS is-down.**+> **SETTLED LIVE, 2026-09-30** (see "Live proof 2"): the full gate passed every+> launch claim through the bridge ab hosts — `shellExecute`, attributed,+> `Run Begin` at 28.8 s, the helper sign-in step passed without a sign-in, COM+> attached at 139.8 s. That answers items 1 and 2 below for this machine. Items+> 3–7 are unchanged: the run did not need to kill a stalled chain, did not run+> `--probe-uia`, and did not meet a wedged SOLIDWORKS.++Nothing below had touched a real SOLIDWORKS when it was written.+`tools/launch-gate.py` is written to settle every line of it, and **must only be+run when Drew says SOLIDWORKS is down.**  1. **Whether any of `shellExecute` / `powershell` / `explorer` actually reaches    `Run Begin`.** The stall hypothesis (startup context / show state) is@@ -909,6 +916,49 @@ Still unproven live: the launch routes (needs SOLIDWORKS closed), and a graceful `Process.CloseMainWindow()` experiment was blocked by the agent-side auto-mode classifier, see docs/QUIT-INVESTIGATION.md. +## Live proof 2 — every launch claim, through the bridge ab hosts (2026-09-30, 0.8.9)++adom-bridge 2.1.163 auto-installed the released 0.8.9 into+`bridges-cache\solidworks` and routed every `swx_*` verb to it; the gates reached+it through `adom-bridge --target adom_drew2 <verb>`, not through a side-loaded+copy (that route is dead — see below).++| gate | result |+|---|---|+| launch (`tools/launch-gate.py`) | PASSED every launch claim: route `shellExecute`, attributed, `Run Begin` 28.8 s, helper sign-in step passed without a sign-in, COM attached 139.8 s |+| ownership measurement | PASSED both halves: a pathless `Part2` preserved by ownership (25,959 bytes, verified, listed from the manifest); Drew's saved `R_0603_1608Metric_1.step-1.sldprt` closed with its original untouched — 32,311 bytes and the same mtime after the sweep |+| drain (`tools/drain-gate.py`) | 5 / 5 |+| quit (`tools/quit-gate.py`) | PASSED at rung (a): process gone 29.5 s, servers reaped, nothing on screen, no crash reporter, rungs reported, SOLIDWORKS relaunched by the bridge 54.7 s later (`docs/QUIT-INVESTIGATION.md` §5.14) |++**What the same day found wrong — SEV-1, fixed in 0.9.0.** `bridges-cache\solidworks`+was CREATED 2026-09-18 09:42, by ab's auto-install of 0.8.9: the updater+re-extracts the whole directory, and that directory was also where this bridge+kept its data. Every recovery folder from 2026-09-17, the ledger, the ownership+registry and the scratch documents were gone. Only test documents were in them.+Since 0.9.0 the data root is `%LOCALAPPDATA%\Adom Bridge\solidworks-data\` — a+sibling of the install cache, never inside it; the first start migrates whatever+is still in the old location (copied, verified byte for byte, originals left+where they were, each file once); `swx_recover_list` still reads the old+location and marks what it finds there `atRiskFromUpdate`; and a sweep refuses+(`recovery_inside_install_dir`) — before writing or closing anything — if the+recovery folder would land under the install directory or the install cache,+however the path is spelled. One limit, stated rather than hidden: the update+that installs 0.9.0 re-extracts the old directory BEFORE 0.9.0 first runs, so on+a machine ab updates from 0.8.9 the migration will most likely find nothing —+whatever 0.8.9 wrote there after 2026-09-18 is lost at the moment of that+install. The move is what protects every update after it.++**How the bridge is tested on the machine now.** The dev deploy pattern+(`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+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.+ ## The rule this project keeps having to learn: unit-tested is not wired  Five guards in this bridge have shipped **unit-tested and with no production
docs/QUIT-INVESTIGATION.md+113−1
@@ -226,6 +226,25 @@ a save prompt and the quit stalled" call for completely different ladders. §5 therefore measures rung (a) *first*, on a genuinely `Some(0)` session, and records the result in the ledger. +> ### SETTLED, LIVE, 2026-09-17 — it was the save prompt.+>+> On a bridge-launched session emptied by `swx_close_all_safely`,+> `swx_stop {userAuthorized, maxRung:"a"}` **quit SOLIDWORKS on rung (a)**:+> `UserControl=false`, `ExitApp`, every COM reference released, and the process+> verified absent from the census. `swx_shutdown` then removed a stray empty+> instance (pid 145188), after which `tasklist` showed no `sldworks.exe` at all.+>+> **`ExitApp` is NOT declined at zero documents on this edition.** The+> long-standing "declined even with 0 documents" belief was a SAVE-PROMPT STALL+> read as a refusal — exactly the alternative this section said had to be+> separated first, and exactly the reason the ladder was built pinned at rung (a)+> rather than escalating. Nothing above rung (a) has ever been needed on this+> machine.+>+> The practical consequence is the one this whole document was written to reach:+> the blocker was never the quit, it was the documents. Preserve them first+> (§5.12) and the quit is one call.+ ---  ## 1. The API semantics of quitting@@ -257,6 +276,14 @@ Three things the documentation does **not** say, and all three matter: So "declined" is not a documented state. What *is* documented, repeatedly, is that the surrounding COM ownership decides whether the process actually goes away. +**Measured live, 2026-09-17, on SOLIDWORKS Connected:** with zero documents open+and `UserControl` set false, `ExitApp` returned without an HRESULT failure and the+process was gone from the census — the first rung, on the first attempt. The+"declined" state this section declined to assume was never real; what had been+observed under that name was a save prompt on a session that still had documents+open. Ownership was the whole story, as the documentation implies and as §1.2+argues.+ ### 1.2 `ISldWorks::UserControl` — the flag that decides who owns the lifetime  Official help (`…ISldWorks~UserControl.html`, via snippet):@@ -1275,7 +1302,7 @@ Neither is about quitting, both belong to the same posture:    `verbs.rs` only clears on the success arm). **Clear the host registry after a    census-confirmed exit, never before the verification.** -### 5.11 As built — `swx_stop` v2, shipped in 0.5.2, hardened in 0.5.3 (mock-proven, **live-unproven**)+### 5.11 As built — `swx_stop` v2, shipped in 0.5.2, hardened in 0.5.3 (**LIVE-PROVEN on rung (a), 2026-09-17; the full gate cycle again 2026-09-30 through the hosted bridge — §5.14**)  Written after the code, from the code. Where the build deviates from §5.1–§5.10 the deviation is named here rather than quietly absorbed, and the doc above is@@ -1740,6 +1767,37 @@ attribution — the user may quit a session this bridge did not start — and th window-message rungs become available, which matters when COM is wedged behind a licence dialog. It still never kills anything. +### 5.13 The licence dialog blocks everything, measured 2026-09-17++The safe-close sweep stopped on a dialog and refused to go further, which is+correct. Read afterwards with UI Automation (read-only), the window was:++```text+title:   License Manager+text:    Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional:+         Unable to connect to server. The session will end after the next 5+         unsuccessful attempts.+buttons: OK+```++It appeared when the **3DEXPERIENCE licence heartbeat failed** — it coincided+with the desktop dropping off the relay — and while it is up **both the sweep and+the quit are blocked**, because every one of this bridge's rules stops at a+dialog and never answers one.++Two consequences worth stating plainly. First, a licence-server outage makes the+session unclosable by this bridge until a person dismisses the dialog; the work is+not at risk (the sweep preserves before it closes, and it had not closed anything)+but the session cannot be ended. Second, the message is a COUNTDOWN — five more+failed checks and the session ends by itself — which is exactly the kind of thing+a caller must be shown rather than told "a dialog is up". Since 0.8.4 the report+carries the dialog's text and buttons for that reason.++This bridge still does not press OK. Whether it ever may, for a dialog whose only+control is an acknowledgement, is a policy question for Drew — `acknowledgeOnly`+reports the fact so that decision can be made on evidence, and `neverAnswer`+remains true until it is.+ **In this repo:** `docs/LAUNCH-RESILIENCE.md` (the attribution law, the kill allowlist, the launch state machine) · `docs/FUSION-PARITY-BRIEF.md` §"The gate was closed in the wrong direction" (the only ledger-backed decline we have) ·@@ -1755,3 +1813,57 @@ closed in the wrong direction" (the only ledger-backed decline we have) · `%APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\{swxJRNL.swj,swxJRNL.BAK,3dexperience-log.json,SWStdSessionsData.xml}`, `%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\ledger.jsonl`, `%TEMP%\swx*`, `HKCU\Software\SolidWorks\*`.++### 5.14 LIVE-PROVEN 2026-09-30 — the full gate cycle, through the bridge ab hosts++`tools/quit-gate.py` ran its whole cycle on Drew's machine against the RELEASED+bridge: adom-bridge 2.1.163 had auto-installed 0.8.9 into+`bridges-cache\solidworks` and routes `swx_*` verbs to it. Every claim below came+from the machine, not from the bridge's own report.++| Claim | Measured |+|---|---|+| The session was bridge-launched (the gate's precondition) | yes — attribution intact |+| The quit succeeded on rung (a) | yes: `UserControl=false` → `ExitApp` → every COM reference released |+| The process is gone (pid AND creation time absent from the census) | 29.5 s after the rung |+| Per-session children reaped, platform daemons left alone | yes |+| Nothing on screen afterwards, no crash reporter | yes, both |+| Every rung attempted is reported | yes |+| The bridge can launch again after a graceful quit | SOLIDWORKS relaunched by the bridge 54.7 s later |++The same day: the launch gate passed every launch claim, the drain gate passed 5+of 5, and the ownership measurement passed both halves (a pathless `Part2`+preserved by ownership, 25,959 bytes verified, listed from the manifest; Drew's+saved `R_0603_1608Metric_1.step-1.sldprt` closed with its original untouched —+same 32,311 bytes and the same mtime after the sweep). See+`docs/LAUNCH-RESILIENCE.md` "Live proof 2".++**Two things the gate had to change to run at all, both measured:**++1. **The route.** The gate used to reach the bridge through `shell_execute` ++   `curl.exe` to a side-loaded dev copy on `127.0.0.1:24695`. adom-bridge 2.1.163+   terminates a second `swx-bridge.exe` within 30 s, so that route is dead. Every+   gate (`tools/{launch,drain,quit}-gate.py`, and `tools/swx-live.py`) now calls+   `adom-bridge --target adom_drew2 --ai-thread <thread> <verb> <json>`, and+   unwraps the routed reply (`result`, when present — ab merges top-level fields+   and adds an `output` string). `SWX_BRIDGE_URL` forces the old loopback route.+   A fast `shell_execute` failure with no output at all (exit 1, under 5 s) is a+   measured transient — the same command ran 5/5 clean in isolation — so it is+   retried ONCE, and the retry is printed.+2. **The grant.** The gate passed `allowUserWrites` + `confirm`, and was refused+   `user_writes_disabled` with attribution intact: the bridge ab hosts never+   carries `SWX_ALLOW_USER_WRITES`, and it should not — the write gate is not what+   authorizes a quit. It now passes `userAuthorized:true` with a reason, which is+   Drew's standing authorization (2026-09-11) as `swx_stop` reads it.++**How this bridge is tested on the machine now.** The dev deploy pattern — a+`deployN.py` side-load of a dev build onto port 24695 next to the released one —+is RETIRED, for the reason in (1). Dev testing is either a PRERELEASE install+through ab, or a renamed exe reached with `SWX_BRIDGE_URL`. **Which of those ab+tolerates is NOT MEASURED yet**: 2.1.163 was seen terminating a second+`swx-bridge.exe`; whether it also terminates a copy under another name on+another port has not been tried, and until it is, that route is a candidate, not+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.
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.6.9",+  "version": "0.9.0",   "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
src/abwin.rs+11
@@ -106,6 +106,17 @@ pub fn classify(title: &str) -> (&'static str, bool, bool) {     {         return ("save_changes", true, true);     }+    // IMPORT DIAGNOSTICS. Measured 2026-09-17: `swImportAutoRunImportDiagnostics`+    // (291) and `swImportNeutralRunDiagnostics` (690) are both TRUE on this+    // install, so a STEP with faulty faces raises this MODAL dialog during the+    // import. It classified as `unknown` before — never auto-answered, which was+    // right, but reported as a generic dialog when it is the single most likely+    // thing to stop an import on this machine. It is still NEVER answered: the+    // choices it offers (attempt to heal, or not) change the geometry the user+    // gets, and that is theirs to make.+    if t.contains("import diagnostic") || t.contains("import diagnos") {+        return ("import_diagnostics", true, true);+    }     if t.contains("recover") {         return ("document_recovery", true, true);     }
src/comhost/com.rs+1126−15
@@ -10,8 +10,16 @@ //!     out-params — an unattended bridge must never park SOLIDWORKS on a dialog //!     nobody will answer. Fall back to legacy `OpenDoc` if the late-bound byref //!     call comes back empty.-//!   * `Save3(1, ...)` / `SaveAs3(path, 0, 1)` — silent, copy-mode; SaveAs3-//!     keys the format off the destination extension.+//!   * `Save3(1, ...)` / `SaveAs3(path, 0, options)` — SaveAs3 keys the format+//!     off the destination extension. The OPTIONS BIT IS LOAD-BEARING and is not+//!     a detail: `swSaveAsOptions_Silent` (1) ALONE RE-POINTS the open document+//!     at the new file, while adding `swSaveAsOptions_Copy` (2) writes the file+//!     and leaves the document attached to where it was. The export verbs want+//!     Copy — the user keeps working on their own file. The safe-close sweep+//!     wants Silent ALONE, because the close that follows it closes the document+//!     by the path the document has AT THAT MOMENT, and that path must be the+//!     recovery file. Passing the wrong one there would close a document whose+//!     content is on disk under a name nothing points at. //!   * Suppressed components (`GetSuppression() == 0`) are excluded from BOMs. //!   * `InterferenceDetectionManager` is always closed with `Done()`, on every //!     path — leaving it open leaves SOLIDWORKS in a modal-ish state.@@ -635,6 +643,21 @@ impl ComBackend {         //      1. CLOSE BY THE PATH THE VICTIM WAS CHOSEN BY.         //      2. REFUSE any victim whose title another open document shares —         //         and say which paths, so the user can act on it.+        //    WHAT THIS GUARD ACTUALLY PROTECTS, 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 the copy. SOLIDWORKS REFUSED it:+        //    `errors = 65536` (`swFileWithSameTitleAlreadyOpen`).+        //+        //    So on this edition two SAVED documents CANNOT share a title, and+        //    this guard can only ever fire for PATHLESS ones — which is exactly+        //    the state identity-at-birth removes by giving every bridge-created+        //    document a file the moment it exists. That is not a reason to delete+        //    the guard: a document the bridge did not create can still be+        //    pathless, and an older bridge's leftovers still are. It is a reason+        //    to know which case it is for, so nobody "simplifies" it on the+        //    assumption that saved collisions are the common case.         let mut collisions: Vec<Value> = Vec::new();         let victims: Vec<(String, String)> = victims             .into_iter()@@ -807,6 +830,389 @@ impl ComBackend {         }))     } +    /// PRESERVE EVERY OPEN DOCUMENT, THEN CLOSE IT. Drew's standing+    /// authorization, 2026-09-11: *"I despise every time you ask for help+    /// closing. You MUST find/build a way to safely close the docs/program."*+    ///+    /// The authorization is to CLOSE. It is not an authorization to LOSE+    /// anything, and those are different permissions — so the safety property+    /// here is not ownership (irrelevant: the user said yes) but a sequence:+    ///+    ///   **nothing is closed until it is on disk.**+    ///+    /// Every document, saved or not, bridge-created or the user's, is made CLEAN+    /// AND PATHED before anything closes. A dirty document is SAVED TO RECOVERY:+    /// `SaveAs3` with Silent ALONE (never Copy), so the OPEN DOCUMENT NOW POINTS+    /// AT THE RECOVERY FILE — which is exactly what makes the close key right,+    /// because the close is by the path the document has at that moment. The+    /// user's original file on disk is never written to; it is simply no longer+    /// what this in-memory document is attached to. A pathless document gets a+    /// file of its own for the first time. Only then does the close happen, by+    /// that path, and a save prompt cannot appear because there is nothing left+    /// unsaved to prompt about.+    ///+    /// ONE DOCUMENT PER CALL, because the server must look for a dialog between+    /// every save and every close.+    ///+    /// 0.7.1 did a whole batch inside one host call and the server censused once+    /// per batch. That was wrong for a reason the design itself implies: a SAVE+    /// can raise a prompt (a missing external reference, a rebuild question), and+    /// a dialog raised by the first save was invisible until every other document+    /// in the batch had already been closed. So `phase` splits the work into the+    /// two steps the server interleaves its census with, and `max`/`batch` go+    /// back to being what they always should have been — a bound on round trips+    /// and wall time, not the unit of safety.+    fn cmd_preserve_and_close(&mut self, args: &Value) -> Result<Value, HostError> {+        match args.get("phase").and_then(|v| v.as_str()).unwrap_or("preserve") {+            "preserve" => self.preserve_one(args),+            "close" => self.close_one(args),+            other => Err(HostError::msg(format!(+                "unknown preserve_and_close phase {other:?} — expected \"preserve\" or \"close\""+            ))),+        }+    }++    /// PRESERVE ONE DOCUMENT. Drew's standing authorization, 2026-09-11: *"I+    /// despise every time you ask for help closing. You MUST find/build a way to+    /// safely close the docs/program."*+    ///+    /// The authorization is to CLOSE. It is not an authorization to LOSE+    /// anything, and those are different permissions — so the safety property+    /// here is not ownership (irrelevant: the user said yes) but a sequence:+    ///+    ///   **nothing is closed until it is on disk.**+    ///+    /// The document at `index` in the walk is made CLEAN AND PATHED. A dirty one+    /// is SAVED TO RECOVERY: `SaveAs3` with Silent ALONE (never Copy), so the+    /// OPEN DOCUMENT NOW POINTS AT THE RECOVERY FILE — which is what makes the+    /// close key right, because the close is by the path the document has at that+    /// moment. The user's original file on disk is never written to; it is simply+    /// no longer what this in-memory document is attached to. A pathless document+    /// gets a file of its own for the first time.+    ///+    /// The index is a position in the walk, not an identity, and that is safe+    /// only because NOTHING CLOSES during this phase: the walk order is stable+    /// while the server steps through it.+    fn preserve_one(&mut self, args: &Value) -> Result<Value, HostError> {+        let sw = self.app(false, true)?;+        let recovery = args+            .get("recoveryDir")+            .and_then(|v| v.as_str())+            .filter(|s| !s.is_empty())+            .ok_or_else(|| HostError::msg("recoveryDir is required"))?+            .to_string();+        let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+        let count = Self::document_count(&sw);+        // Walk live pointers, never a title. Only as far as the document asked+        // for — the walk is bounded and the server pays for each step.+        let (walked, _stop) = Self::walk_documents(+            &sw,+            index + 1,+            std::time::Duration::from_secs(super::host::DRAIN_WALK_BUDGET_SECS),+        )?;+        let Some(doc) = walked.get(index) else {+            // THE WALK IS EXHAUSTED. Not an error: it is how the server learns+            // there is nothing further to preserve.+            return Ok(json!({+                "phase": "preserve", "index": index, "walked": 0,+                "documentCount": count, "record": Value::Null, "ready": Value::Null,+            }));+        };+        let title = doc.string("GetTitle", &[]).unwrap_or_default();+        let path = doc.string("GetPathName", &[]).unwrap_or_default();+        let dirty = doc.flag("GetSaveFlag", &[]).unwrap_or(true);+        // GETTYPE CAN FAIL, AND AN UNREADABLE TYPE IS NOT A PART. 0.7.4 wrote+        // `.unwrap_or(1)`, which turned every failed COM read into "part" — the+        // LOWEST recovery floor of the three — and then named the recovery file+        // `.SLDPRT`, from which the verdict read the kind back. One failed read+        // therefore closed a truncated assembly against a part-sized floor. This+        // is a COM call on a document being closed under memory pressure, which+        // is the condition this sweep exists for.+        let kind = match doc.int("GetType", &[]) {+            Some(2) => Some(crate::disk::DocKind::Assembly),+            Some(3) => Some(crate::disk::DocKind::Drawing),+            Some(1) => Some(crate::disk::DocKind::Part),+            // Unreadable, or a type this bridge does not know: UNKNOWN, carried+            // as such all the way to the verdict.+            _ => None,+        };+        // WHEN GETTYPE FAILS, ASK THE EVIDENCE ALREADY IN HAND.+        //+        // The document's OWN PATH is the strongest of it: the user's file, named+        // by SOLIDWORKS, on disk, read from this same document a moment ago. Its+        // title comes next, and is weaker than it looks — SOLIDWORKS returns the+        // title as the shell shows it, so with Explorer's default "hide+        // extensions for known file types" a saved assembly's title is `bracket`,+        // not `bracket.SLDASM`.+        //+        // THIS IS NOT THE CIRCULARITY 0.7.5 REMOVED, and the difference is+        // provenance: `path` and `title` come from SOLIDWORKS and describe the+        // document. The recovery file's name is chosen BY THIS SWEEP from the+        // kind, so reading a kind back out of THAT can only repeat whatever it+        // already assumed.+        let kind = kind+            .or_else(|| crate::disk::DocKind::from_extension(&path))+            .or_else(|| crate::disk::DocKind::from_extension(&title));+        let kind_name = kind.map(|k| json!(k.type_name())).unwrap_or(Value::Null);+        let pathless = path.trim().is_empty();+        let mut ready = Value::Null;+        let record = if !dirty && !pathless {+            // Already on disk and unchanged: nothing to preserve.+            ready = json!({+                "path": path, "docType": kind_name, "writtenHere": false,+                "originalBytes": Value::Null,+            });+            // CLOSED IN PLACE. The user's own file is this document's+            // preservation, so its size and modification time are measured here —+            // read-only — and recorded: that is what lets swx_recover_list later+            // say "closed at its own path, untouched" and mean it, or say that+            // something has written to it since. Measured live 2026-09-30: the+            // record had nothing for such a document at all.+            let original_bytes = std::fs::metadata(&path).ok().map(|m| m.len());+            let original_modified_ms = crate::scratch::modified_ms(&path);+            json!({+                "title": title, "path": path, "recoveryPath": Value::Null,+                // Nothing was written for this one: it was already its own file+                // on disk and unchanged, so there is no size and no signature of+                // ours to report.+                "recoveryBytes": Value::Null, "recoverySignatureOk": Value::Null,+                "docKind": kind_name, "originalBytes": original_bytes,+                "originalModifiedMs": original_modified_ms,+                "wasDirty": false, "wasPathless": false, "preserved": true,+            })+        } else {+            // UNIQUE BY CONSTRUCTION, and never an overwrite: if the name is+            // taken, take the next one.+            let stem = if title.trim().is_empty() { "document".to_string() } else { title.clone() };+            // THE ORIGINAL'S SIZE, read before anything is written: when the kind+            // cannot be established this is the other half of the floor — a+            // rescue of a document that had a 4 MB file must not be 30 KB.+            let original_bytes = if pathless {+                None+            } else {+                std::fs::metadata(&path).ok().map(|m| m.len())+            };+            let out = Self::unique_recovery_path(&recovery, &stem, kind);+            // A NAME SOLIDWORKS MAY REFUSE TO WRITE.+            //+            // `SaveAs3` dispatches its output format on the extension, and+            // `.sldrecover` matches no writer. Whether it fails or silently falls+            // back to some default is not something this bridge knows, and+            // guessing it from memory is how a format claim went wrong two passes+            // ago. So it is not guessed: the save is ATTEMPTED, and if it fails+            // the document is re-saved under `.SLDPRT` and the reason recorded.+            //+            // A guessed NAME is tolerable now in a way it was not in 0.7.4,+            // because nothing downstream reads the kind back out of it: the+            // verification still applies the UNKNOWN-kind rules (the strictest+            // floor, and at least the original file's size). The name is a label;+            // the floor is the check.+            let mut out = out;+            // The name is a guess whenever the kind is unknown — whether it was+            // guessed up front (this install) or after a refused neutral name.+            let mut named_as_part = kind.is_none() && !Self::NEUTRAL_EXTENSION_ACCEPTED;+            let first = Self::save_as3(doc, &out, false);+            let failed = match &first {+                Err(_) => true,+                Ok(code) => *code != 0,+            };+            let attempt = if failed && kind.is_none() && out.to_lowercase().ends_with(".sldrecover")+            {+                named_as_part = true;+                out = Self::unique_recovery_path(+                    &recovery,+                    &stem,+                    Some(crate::disk::DocKind::Part),+                );+                Self::save_as3(doc, &out, false)+            } else {+                first+            };+            match attempt {+                Err(e) => json!({+                    "title": title, "path": path, "recoveryPath": Value::Null,+                    "recoveryBytes": 0, "recoverySignatureOk": false,+                    "docKind": kind_name, "originalBytes": original_bytes,+                    "namingFallback": if named_as_part { json!("unknownKindNamedAsPart") } else { Value::Null },+                    "wasDirty": dirty, "wasPathless": pathless, "preserved": false,+                    "why": format!("could not be saved, so it was NOT closed: {}", e.message),+                }),+                Ok(code) => {+                    // ── VERIFY THE FILE STRUCTURALLY, NOT STATISTICALLY. A file+                    //    can EXIST and hold nothing, and it can hold plenty and+                    //    still be a fragment: a partial write on a full disk+                    //    stops wherever it stops. This laptop's host disk has+                    //    been at 100% during this very release series, so it is+                    //    not hypothetical — and the cost of getting it wrong is+                    //    the document being closed against a file with none, or+                    //    half, of its content in it. `recovery_file_verdict`+                    //    reads the OLE compound-document signature and the size,+                    //    and fails closed on anything it cannot vouch for.+                    let now_path = doc.string("GetPathName", &[]).unwrap_or_default();+                    let v = super::host::recovery_file_verdict(&now_path, kind, original_bytes);+                    if code != 0 || now_path.trim().is_empty() || !v.ok {+                        json!({+                            "title": title, "path": path, "recoveryPath": out,+                            "recoveryBytes": v.bytes, "recoverySignatureOk": v.signature_ok,+                            "docKind": kind_name, "originalBytes": original_bytes,+                            "wasDirty": dirty, "wasPathless": pathless, "preserved": false,+                            "why": format!(+                                "SaveAs3 returned {code} and {} — closing this document would have \+                                 discarded the only copy",+                                v.why.unwrap_or_else(|| "the recovery file could not be verified".into())+                            ),+                        })+                    } else if !crate::verbs::same_path(&now_path, &out) {+                        // THE OPEN DOCUMENT MUST NOW BE THE RECOVERY FILE.+                        // SaveAs3 is called with Silent ALONE (no Copy), so it+                        // RE-POINTS the document — and that is what makes the+                        // close key correct. If anyone ever passes Copy here the+                        // document would still be dirty and pathless while this+                        // code closed a file, so the mismatch is checked rather+                        // than assumed.+                        json!({+                            "title": title, "path": path, "recoveryPath": out,+                            "recoveryBytes": v.bytes, "recoverySignatureOk": v.signature_ok,+                            "docKind": kind_name, "originalBytes": original_bytes,+                            "wasDirty": dirty, "wasPathless": pathless, "preserved": false,+                            "why": format!(+                                "the file was written but the open document still reports \+                                 {now_path:?} — it did not re-point to the recovery file, so \+                                 closing by that path would close something else, or nothing. \+                                 (SaveAs3 must be Silent ALONE here, never Copy.)"+                            ),+                        })+                    } else {+                        ready = json!({+                            "path": now_path, "docType": kind_name, "writtenHere": true,+                            "originalBytes": original_bytes,+                        });+                        json!({+                            "title": title, "path": path, "recoveryPath": now_path,+                            "recoveryBytes": v.bytes, "recoverySignatureOk": true,+                            "docKind": kind_name, "originalBytes": original_bytes,+                            // The kind is still UNKNOWN: only the NAME fell back,+                            // and the verification above used the unknown rules.+                            "namingFallback": if named_as_part { json!("unknownKindNamedAsPart") } else { Value::Null },+                            "wasDirty": dirty, "wasPathless": pathless, "preserved": true,+                        })+                    }+                }+            }+        };+        Ok(json!({+            "phase": "preserve", "index": index, "walked": 1,+            "documentCount": count, "record": record, "ready": ready,+        }))+    }++    /// CLOSE ONE DOCUMENT, by the path it reports NOW.+    ///+    /// Reached only after the server has re-censused for a dialog since the save+    /// that made this path, and only for a document `preserve_one` vouched for.+    /// A document this bridge wrote is re-verified here as well: the file must+    /// still be a real document at the moment of the close, not merely at the+    /// moment of the save.+    fn close_one(&mut self, args: &Value) -> Result<Value, HostError> {+        let sw = self.app(false, true)?;+        let path = args+            .get("path")+            .and_then(|v| v.as_str())+            .filter(|s| !s.trim().is_empty())+            .ok_or_else(|| HostError::msg("path is required"))?+            .to_string();+        let written_here = args.get("writtenHere").and_then(|v| v.as_bool()).unwrap_or(false);+        if written_here {+            // THE KIND TRAVELS WITH THE DOCUMENT. Re-deriving it from the file+            // name here would re-introduce exactly the circularity this release+            // removed: the name came from the kind.+            let kind = args+                .get("docKind")+                .and_then(|v| v.as_str())+                .and_then(|t| crate::disk::DocKind::from_type_name(t));+            let original_bytes = args.get("originalBytes").and_then(|v| v.as_u64());+            let v = super::host::recovery_file_verdict(&path, kind, original_bytes);+            if !v.ok {+                return Ok(json!({+                    "phase": "close", "path": path, "closed": false,+                    "documentCount": Self::document_count(&sw),+                    "why": v.why.unwrap_or_else(|| "the recovery file could not be verified".into()),+                }));+            }+        }+        let _ = sw.call("CloseDoc", &[&Var::bstr(&path)]);+        let still_open = Self::open_document_by_name(&sw, &path).is_some();+        if !still_open {+            self.created.retain(|(_, p)| p.as_deref() != Some(path.as_str()));+        }+        Ok(json!({+            "phase": "close", "path": path, "closed": !still_open,+            "documentCount": Self::document_count(&sw),+            "why": if still_open { json!("still open after CloseDoc") } else { Value::Null },+        }))+    }++    /// `<recovery>\<stem>[-n].<ext>`, never overwriting. Several open documents+    /// can share one title (that is half of what makes this bridge's life hard),+    /// so the name is made unique against the FILESYSTEM.+    /// DOES THIS INSTALL ACCEPT A NEUTRAL EXTENSION? **Measured, 2026-09-17, on+    /// the live SOLIDWORKS Connected install: NO.**+    ///+    /// A scripted `SaveAs3` to `.sldrecover` on a throwaway document returned+    /// *"format not supported for …"* and wrote nothing. So the first attempt was+    /// not merely likely to fail — it was certain to, and a certain failure is not+    /// something to spend a COM round trip and a user's wait on during a close.+    ///+    /// The unknown-kind document therefore goes straight to `.SLDPRT`, with+    /// `namingFallback: "unknownKindNamedAsPart"` recorded so nothing downstream+    /// reads that extension as a statement of fact, and with the UNKNOWN-kind+    /// verification unchanged (the strictest floor, and at least the original+    /// file's size). The name is a label; the floor is the check.+    ///+    /// The runtime retry is deliberately KEPT rather than deleted: flip this to+    /// `true` if a future version accepts the neutral name, and the honest name+    /// comes back with the retry already covering the other outcome. What is not+    /// kept is an attempt this bridge has measured to be futile.+    const NEUTRAL_EXTENSION_ACCEPTED: bool = false;++    fn unique_recovery_path(dir: &str, title: &str, kind: Option<crate::disk::DocKind>) -> String {+        // AN UNKNOWN KIND IS NOT GIVEN A GUESSED EXTENSION. Naming a document+        // `.SLDPRT` because its type could not be read states something this+        // bridge does not know, in the one place downstream code was reading it+        // back from. The title's own extension is real evidence — the user or+        // SOLIDWORKS wrote it — so it is used when present; otherwise the file+        // gets a neutral name that claims nothing.+        let ext = match kind {+            Some(k) => k.extension(),+            // NOTHING ANYWHERE SAID WHAT THIS IS — no type, no path extension, no+            // title extension: a pathless document whose type could not be read.+            // A neutral name would be the honest label, and this install refuses+            // to write one (see NEUTRAL_EXTENSION_ACCEPTED), so the file is named+            // as a part and the RECORD carries the fact that the name is a guess.+            None if Self::NEUTRAL_EXTENSION_ACCEPTED => "sldrecover",+            None => crate::disk::DocKind::Part.extension(),+        };+        let stem_raw = title.rsplit_once('.').map(|(a, _)| a).unwrap_or(title);+        let stem: String = stem_raw+            .chars()+            .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })+            .collect();+        let stem = stem.trim_matches('_');+        let stem = if stem.is_empty() { "document" } else { stem };+        let sep = if dir.ends_with('\\') || dir.ends_with('/') { "" } else { "\\" };+        let first = format!("{dir}{sep}{stem}.{ext}");+        if !std::path::Path::new(&first).exists() {+            return first;+        }+        for n in 1..10_000 {+            let p = format!("{dir}{sep}{stem}-{n}.{ext}");+            if !std::path::Path::new(&p).exists() {+                return p;+            }+        }+        format!("{dir}{sep}{stem}-{}.{ext}", crate::launchos::epoch_ms())+    }+     /// ONE RUNG of the quit ladder (docs/QUIT-INVESTIGATION.md §5.3).     ///     /// Every rung ENDS with every COM reference released, and rungs (b)-(f)@@ -1146,7 +1552,14 @@ impl ComBackend {                 let _ = std::fs::create_dir_all(parent);             }         }-        // swSaveAsOptions_Silent = 1, swSaveAsOptions_Copy = 2+        // swSaveAsOptions_Silent = 1, swSaveAsOptions_Copy = 2.+        //+        // `copy: false` is SILENT ALONE, and that RE-POINTS the open document at+        // `out`. The safe-close sweep depends on exactly that (see+        // `cmd_preserve_and_close`): it reads the close key back off the document+        // after this call, and the key must be the recovery file. The export+        // verbs pass `copy: true` for the opposite reason — the user must keep+        // working on the document they had.         let options = if copy { 1 | 2 } else { 1 };         let r = doc.call("SaveAs3", &[&Var::bstr(out), &Var::i4(0), &Var::i4(options)])?;         // Belt and braces: a build that ever DOES return a bool here still reads@@ -1551,7 +1964,11 @@ fn literal_var(l: &Literal) -> Var {         Literal::Bool(b) => Var::boolean(*b),         // VT_EMPTY is how "argument omitted" reaches a late-bound optional         // [in] object parameter — SelectByID2's callout, LoadFile4's import data.-        Literal::Null => Var::empty(),+        // NOTHING, not "omitted" — see `Var::null_dispatch`. SelectByID2's+        // callout parameter is the documented use of this literal, and it is an+        // `[in]` object like LoadFile4's importData, which answered+        // `Type mismatch. (0x80020005)` to VT_EMPTY on the live install.+        Literal::Null => Var::null_dispatch(),     } } @@ -1785,6 +2202,22 @@ impl ComBackend {         // property, it is a bug — Drew authorized closing his own documents and         // the bridge told him no.         let env_on = crate::writes::user_writes_enabled();+        // DREW'S STANDING AUTHORIZATION for ENDING THE SESSION, honoured by the+        // host's own veto so the two layers agree. `userAuthorized:true` with a+        // reason grants the quit and nothing else — see writes::authorize. The+        // host still checks it for itself rather than trusting the server's word,+        // which is the point of being an independent veto.+        let authorized_quit = always+            && args.get("userAuthorized").and_then(|v| v.as_bool()).unwrap_or(false)+            && args+                .get("reason")+                .and_then(|v| v.as_str())+                .map(|r| r.trim().len() >= 8)+                .unwrap_or(false);+        if authorized_quit {+            return Ok(());+        }+         let missing = crate::writes::missing_opt_in(args);         if env_on && missing.is_empty() {             return Ok(());@@ -2158,6 +2591,7 @@ impl Backend for ComBackend {             "import" => self.cmd_import(args),              "close_import_temps" => self.cmd_close_import_temps(args),+            "preserve_and_close" => self.cmd_preserve_and_close(args),              // THE QUIT LADDER. One rung per call; the SERVER orchestrates and             // measures, because the measurement must not be a COM call.@@ -2911,12 +3345,422 @@ impl ComBackend {         }))     } +    /// OPEN A NEUTRAL FILE AS A LINKED PART — the 3D Interconnect route.+    ///+    /// `OpenDoc6(path, swDocPART, options, "", byref errors, byref warnings)`.+    /// The options value is `swOpenDocOptions_Silent` (1), the same one every+    /// other open in this bridge uses and for the same reason: an unattended+    /// bridge must never park SOLIDWORKS on a dialog nobody will answer. **That+    /// choice is not yet measured for THIS route** — the live run that proved the+    /// route exists did not vary the options — so it is reported in the reply+    /// rather than assumed silently.+    ///+    /// The result is a NEW PART holding a linked 3D Interconnect feature that+    /// points back at the source file. It is `linked: true`, and the reply says+    /// so: a caller who thinks it has a self-contained part would be wrong about+    /// what moving or deleting the source file does to it.+    fn import_via_opendoc6(+        &mut self,+        sw: &Disp,+        path: &str,+        before: i32,+        interconnect: Option<bool>,+        diagnostics: Option<bool>,+        args: &Value,+    ) -> Result<Value, HostError> {+        // (`dissolve` is refused in `cmd_import`, above the route choice, so it+        // fails closed on every route rather than only on this one.)+        let mut errors = 0i32;+        let mut warnings = 0i32;+        // MEASURED ENUM VALUES, for whoever runs the matrix next.+        //+        // `swOpenDocOptions_e`: Silent=1, ReadOnly=2, ViewOnly=4, RapidDraft=8,+        // LoadModel=16, AutoMissingConfig=32, OverrideDefaultLoadLightweight=64,+        // LoadLightweight=128, DontLoadHiddenComponents=256,+        // LoadExternalReferencesInMemory=512, OpenDetailingMode=1024,+        // LDR_EditAssembly=2048, SpeedPak=4096, AdvancedConfig=8192.+        //+        // `swFileLoadError_e`: swGenericError=1, swFileNotFoundError=2,+        // swInvalidFileTypeError=1024, swFileRequiresRepairError=2097152,+        // swApplicationBusy=8388608, swConnectedIsOffline=16777216.+        //+        // Live 2026-09-17 with options=1 and docType=1 on two real STEP files:+        // no document, errors=2097152 (swFileRequiresRepairError), warnings=0.+        // So this invocation is MEASURED WRONG, not measured right — the+        // defaults stay only until the matrix says what to change them to.+        let options = args.get("openOptions").and_then(|v| v.as_i64()).unwrap_or(1) as i32;+        let doc_type =+            args.get("docType").and_then(|v| v.as_i64()).unwrap_or(Self::DOC_TYPE_PART as i64) as i32;+        let invocation = json!({+            "method": "OpenDoc6",+            "args": {+                "path": path,+                "docType": doc_type,+                "options": options,+                "configuration": "",+                "errors": "byref i4",+                "warnings": "byref i4",+            },+        });+        let opened_call = sw+            .call(+                "OpenDoc6",+                &[+                    &Var::bstr(path),+                    &Var::i4(doc_type),+                    &Var::i4(options),+                    &Var::bstr(""),+                    &Var::byref_i4(&mut errors as *mut i32),+                    &Var::byref_i4(&mut warnings as *mut i32),+                ],+            )+            .map_err(|e| {+                let detail = e.detail.clone();+                let data = e.data.clone();+                let mut err = HostError::detailed(+                    format!(+                        "SOLIDWORKS could not open {path} as a 3D Interconnect part \+                         (errors={errors} warnings={warnings}). 3D Interconnect is ON, so a \+                         neutral file is opened rather than translated."+                    ),+                    detail.unwrap_or_else(|| e.message.clone()),+                );+                err.data = Some(json!({+                    "route": "OpenDoc6",+                    "invocation": invocation.clone(),+                    "interconnect": interconnect,+                    "importDiagnosticsAutoRun": diagnostics,+                    "openDoc6": data,+                    "errors": errors,+                    "warnings": warnings,+                    "path": path,+                }));+                err+            });+        // A CALL THAT THREW MAY STILL HAVE OPENED SOMETHING. Ask, before+        // reporting a failure that would leave it open and untracked.+        let opened = match opened_call {+            Ok(v) => v.as_dispatch().map(Disp),+            Err(mut err) => {+                let recount = self.claim_document_born_from_a_failure(sw, before, &mut err);+                if let Some(Value::Object(m)) = err.data.as_mut().map(|d| d) {+                    m.insert("afterFailure".into(), recount);+                }+                return Err(err);+            }+        };+        // OWN IT THE MOMENT IT EXISTS — the same rule the translator route+        // learned the hard way. The document this produces is PATHLESS (its title+        // comes from the source file), so without registration at birth the drain+        // could not identify it as the bridge's own.+        let born = opened.as_ref().map(Self::doc_info);+        if let Some(doc) = born.clone() {+            self.register_created(&doc);+        }+        let after = match Self::document_count(sw) {+            Some(c) => c,+            None => {+                let mut e = HostError::detailed(+                    "the import may or may not have completed: SOLIDWORKS stopped reporting how \+                     many documents are open. Check swx_docs before retrying — retrying blind \+                     could import the file twice."+                        .to_string(),+                    "GetDocumentCount returned nothing".to_string(),+                );+                e.created = born;+                return Err(e);+            }+        };+        let net = after - before;+        let Some(doc) = opened else {+            // F4 — NET > 0 WITH NO HANDLE. `OpenDoc6` returned nothing, and the+            // count says a document appeared anyway. The translator route has+            // recovered the handle from `ActiveDoc` since 0.5.x for exactly this+            // shape; this route returned an error and owned nothing, so whatever+            // had opened was unowned, uncounted and unmentioned — the leak+            // `register_created` exists to prevent, one route later.+            if after > before {+                if let Some(recovered) = sw.obj("ActiveDoc", &[]).ok().flatten() {+                    let info = Self::doc_info(&recovered);+                    self.register_created(&info);+                    return Ok(json!({+                        "imported": info,+                        "via": "OpenDoc6 (handle recovered from ActiveDoc)",+                        "invocation": invocation,+                        "interconnect": interconnect,+                        "interconnectSettingReadable": interconnect.is_some(),+                        "linked": true,+                        "sourcePath": path,+                        "errors": errors,+                        "warnings": warnings,+                        "documentsOpened": after - before,+                        "netCountOk": after - before == 1,+                        "documentCount": after,+                        "_hint": format!(+                            "OpenDoc6 returned no handle but a document appeared, so the handle was \+                             recovered from ActiveDoc and the document is OWNED by this bridge — \+                             it can be closed and drained. {}",+                            import_open_hint(after - before)+                        ),+                    }));+                }+            }+            return Err(HostError::detailed(+                format!(+                    "SOLIDWORKS opened nothing for {path} (errors={errors} warnings={warnings}). \+                     3D Interconnect is ON, so this file was opened as a part rather than \+                     translated."+                ),+                format!("OpenDoc6 returned no document; open-document count went {before} -> {after}"),+            )+            .with_data(json!({+                "route": "OpenDoc6",+                "invocation": invocation.clone(),+                "interconnect": interconnect,+                "importDiagnosticsAutoRun": diagnostics,+                "errors": errors,+                "warnings": warnings,+                "path": path,+            })));+        };+        Ok(json!({+            "imported": Self::doc_info(&doc),+            "via": "OpenDoc6 (3D Interconnect)",+            "invocation": invocation,+            // WHAT THE USER'S SETTING DECIDED, reported rather than implied.+            "interconnect": interconnect,+            "interconnectSettingReadable": interconnect.is_some(),+            // AND WHAT THAT MEANS FOR THE DOCUMENT: it is not self-contained.+            "linked": true,+            "sourcePath": path,+            "openOptions": options,+            "docType": doc_type,+            "openOptionsMeasured": false,+            "importDiagnosticsAutoRun": diagnostics,+            "errors": errors,+            "warnings": warnings,+            "documentsOpened": net,+            "netCountOk": net == 1,+            "documentCount": after,+            "_hint": format!(+                "{}3D Interconnect is ON in this user's SOLIDWORKS, so {path} was OPENED as a part \+                 holding a LINKED feature rather than translated — the part is NOT self-contained, \+                 and moving or deleting the source file changes it. This bridge never alters that \+                 preference. `dissolve:true` is refused until the breaking API is measured on this \+                 install.",+                import_open_hint(net)+            ),+        }))+    }++    /// A CALL THREW — DID IT LEAVE A DOCUMENT BEHIND ANYWAY?+    ///+    /// The import counts ONCE, before it starts, and every success path re-counts+    /// afterwards. The THROWING paths did not: a `LoadFile4` or `OpenDoc6` that+    /// raised a COM exception went straight to an error without asking whether a+    /// document had appeared regardless. If one had, it was open, untracked, and+    /// this host would later refuse to close it — because its own record said it+    /// had never made one, which is the exact leak `register_created` exists to+    /// prevent.+    ///+    /// So the error path re-counts, and if the session grew it takes ownership of+    /// the active document and reports it. Ownership at birth is not a+    /// happy-path rule.+    fn claim_document_born_from_a_failure(+        &mut self,+        sw: &Disp,+        before: i32,+        err: &mut HostError,+    ) -> Value {+        let after = match Self::document_count(sw) {+            Some(c) => c,+            None => return Value::Null,+        };+        if after <= before {+            return json!({"documentCountBefore": before, "documentCountAfter": after, "appeared": false});+        }+        let born = sw.obj("ActiveDoc", &[]).ok().flatten().map(|d| Self::doc_info(&d));+        if let Some(info) = born.clone() {+            self.register_created(&info);+            err.created = Some(info);+        }+        json!({+            "documentCountBefore": before,+            "documentCountAfter": after,+            // A DOCUMENT APPEARED DESPITE THE FAILURE, and it is now owned — so+            // the drain and the safe close can both reach it.+            "appeared": true,+            "claimed": born,+        })+    }++    /// `LoadFile2(path, "")` ALONE — for the matrix.+    ///+    /// In `auto` this only ever runs as a fallback after `LoadFile4`, so its+    /// answer has always been entangled with whatever the first call did to the+    /// session. Asked for by name it runs first and by itself, which is the only+    /// way its `false` — unexplained since 2026-09-17 — can be attributed to+    /// anything.+    fn import_via_loadfile2(+        &mut self,+        sw: &Disp,+        path: &str,+        before: i32,+        interconnect: Option<bool>,+        diagnostics: Option<bool>,+    ) -> Result<Value, HostError> {+        let invocation = json!({+            "method": "LoadFile2",+            "args": {"path": path, "arg": ""},+        });+        let call = sw.call("LoadFile2", &[&Var::bstr(path), &Var::bstr("")]);+        let failure = call.as_ref().err().map(|e| {+            json!({"message": e.message, "detail": e.detail, "code": e.code})+        });+        let returned = call.as_ref().ok().and_then(|v| v.as_bool());+        if returned != Some(true) {+            return Err(HostError::detailed(+                format!(+                    "SOLIDWORKS could not import {path} by LoadFile2 alone (returned {returned:?})"+                ),+                failure+                    .as_ref()+                    .and_then(|f| f.get("detail").and_then(|d| d.as_str()).map(|s| s.to_string()))+                    .unwrap_or_else(|| "LoadFile2 returned false".into()),+            )+            .with_data(json!({+                "route": "LoadFile2",+                "invocation": invocation,+                "interconnect": interconnect,+                "importDiagnosticsAutoRun": diagnostics,+                "loadFile2": {"returned": returned, "failure": failure},+                "path": path,+            })));+        }+        let doc = sw+            .obj("ActiveDoc", &[])?+            .ok_or_else(|| HostError::msg("LoadFile2 reported success but no document became active"))?;+        let info = Self::doc_info(&doc);+        // OWN IT THE MOMENT IT EXISTS, exactly as the other routes do.+        self.register_created(&info);+        let after = Self::document_count(sw);+        let net = after.map(|a| a - before);+        Ok(json!({+            "imported": info,+            "via": "LoadFile2",+            "invocation": invocation,+            "interconnect": interconnect,+            "interconnectSettingReadable": interconnect.is_some(),+            "importDiagnosticsAutoRun": diagnostics,+            "linked": false,+            "errors": 0,+            "documentsOpened": net,+            "netCountOk": net == Some(1),+            "documentCount": after,+            "_hint": "Imported by LoadFile2 asked for BY NAME — a measurement route, not the \+                      default. It left one new open, unsaved document.",+        }))+    }++    /// WHY `dissolve:true` IS REFUSED RATHER THAN GUESSED.    /// WHY `dissolve:true` IS REFUSED RATHER THAN GUESSED.+    ///+    /// Breaking a 3D Interconnect link is a WRITE to the user's document, and the+    /// call that does it has not been measured on this install. This project has+    /// already shipped one confident claim about somebody else's format from+    /// memory — the OLE compound-file signature of pass 23, which was wrong for+    /// every modern SOLIDWORKS file — and that one only cost a refusal. A wrong+    /// guess here modifies a document.+    ///+    /// So the refusal names exactly what has to be measured, and the safe route+    /// that needs no new API at all.+    fn dissolve_refusal() -> HostError {+        let mut e = HostError::coded(+            "dissolve_not_measured",+            "this bridge will not break a 3D Interconnect link with an API it has not verified on \+             this install. The import itself went ahead as a LINKED part (that is what 3D \+             Interconnect does); nothing was changed.",+        );+        e.detail = Some(+            "To settle it, measure which call actually breaks the link on this edition — the \+             candidates are ModelDocExtension::BreakAllExternalFileReferences2 and the 3D \+             Interconnect feature's own Dissolve — and verify the EFFECT (the linked feature is \+             gone and the body survives), not the return value. Until then: import linked, then \+             dissolve in the SOLIDWORKS UI, or turn 3D Interconnect off in Options and import \+             again, which this bridge will route through LoadFile4."+                .into(),+        );+        e+    }++    /// `swMultiCAD_Enable3DInterconnect` — **measured live, 2026-09-17: TRUE.**+    ///+    /// The id and the neighbouring diagnostics toggles were read from the+    /// installed `SolidWorks.Interop.swconst.dll` at+    /// `C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\`+    /// and then read back off the running application with+    /// `GetUserPreferenceToggle`:+    ///+    /// ```text+    /// 691  swMultiCAD_Enable3DInterconnect        TRUE+    /// 291  swImportAutoRunImportDiagnostics       TRUE+    /// 690  swImportNeutralRunDiagnostics          TRUE+    /// 639  swForceEnableImportDiagnosis           FALSE+    /// ```+    const PREF_3D_INTERCONNECT: i32 = 691;+    /// `swImportAutoRunImportDiagnostics` — TRUE on this install, which is why a+    /// STEP with faulty faces raises a MODAL Import Diagnostics dialog.+    const PREF_AUTO_IMPORT_DIAGNOSTICS: i32 = 291;+    /// `swDocPART` — the document type an interconnect open produces.+    const DOC_TYPE_PART: i32 = 1;++    /// Read a user preference toggle. `None` when SOLIDWORKS would not answer —+    /// an unreadable preference is UNKNOWN, and the import reports it as such+    /// rather than assuming the default.+    fn pref_toggle(sw: &Disp, id: i32) -> Option<bool> {+        sw.call("GetUserPreferenceToggle", &[&Var::i4(id)]).ok().and_then(|v| v.as_bool())+    }+     /// `swx_import` — bring a neutral CAD file in as a new document.     ///-    /// `LoadFile4(path, "r", importData, byref errors)` is the modern route; the-    /// `importData` argument is the optional object our `null` VARIANT covers.-    /// NOT proven live (SOLIDWORKS Connected was down when this was written), so-    /// it falls back to `LoadFile2(path, "")`, which at least reports honestly.+    /// **WHAT IS MEASURED, AND WHAT IS NOT.** This distinction is the whole+    /// state of this verb, and two releases in a row have gone wrong by blurring+    /// it.+    ///+    /// MEASURED on this install (2026-09-17):+    ///+    /// * `swMultiCAD_Enable3DInterconnect` (691) is **TRUE**.+    /// * `LoadFile4` with the correct variant encoding EXECUTES and returns no+    ///   document, `errors = 1` (`swGenericError`).+    /// * `LoadFile2` returns **false**. Still unexplained.+    /// * `OpenDoc6(path, swDocPART, 1, "", …)` on a real `.step` returns no+    ///   document, `errors = 2097152` (`swFileRequiresRepairError`), warnings 0,+    ///   open-document count unchanged — for a 25 KB McMaster AP203 file and a+    ///   4.6 MB one alike.+    ///+    /// **NOT MEASURED:** what the 691 toggle actually implies about the route. I+    /// wrote in 0.8.0 that it meant "OpenDoc6 with swDocPART", from recollection,+    /// and the live run says that is wrong. No route is known to work on this+    /// edition today.+    ///+    /// So this verb carries a MEASUREMENT SURFACE (`route`, `openOptions`,+    /// `docType`, `withImportData`, `argString`) and echoes the exact+    /// `invocation` it made. `route:"auto"` keeps the shipped behaviour; the rest+    /// exist so the matrix can be run against the real application, which is the+    /// only thing that can answer this — PowerShell cannot drive SOLIDWORKS+    /// Connected at all (every member call fails `TYPE_E_ELEMENTNOTFOUND`+    /// 0x8002802B, because Connected does not expose the type info the COM+    /// adapter needs), so raw IDispatch through this bridge is the only+    /// instrument there is.+    ///+    /// **THE TOGGLE IS NEVER CHANGED.** Not set, and not flipped-and-restored: a+    /// crash between the two halves would leave the user's SOLIDWORKS configured+    /// differently than they left it, and a CAD setting silently altered by a+    /// tool is exactly the kind of harm this bridge exists not to do. The+    /// preference is READ, reported, and obeyed.+    ///+    /// `LoadFile2` returning `false` on this install is still UNEXPLAINED. It is+    /// not the interconnect story — it was the route that appeared to work from+    /// 09-05 to 09-11 — and it has not been diagnosed.     fn cmd_import(&mut self, args: &Value) -> Result<Value, HostError> {         let path = args             .get("filePath")@@ -2928,6 +3772,36 @@ impl ComBackend {             return Err(HostError::msg(format!("file not found: {path}")));         }         let sw = self.app(false, true)?;+        // THE USER'S SETTING, READ AND OBEYED — never written. `None` means+        // SOLIDWORKS would not answer, and an unreadable preference is UNKNOWN:+        // the translator route is attempted and the reply says the toggle could+        // not be read, rather than a default being assumed.+        let interconnect = Self::pref_toggle(&sw, Self::PREF_3D_INTERCONNECT);+        let diagnostics = Self::pref_toggle(&sw, Self::PREF_AUTO_IMPORT_DIAGNOSTICS);+        // ── THE MEASUREMENT SURFACE. Named routes and exact arguments, so the+        //    matrix can be run against the live application instead of recalled.+        //    `auto` is the shipped behaviour and the default; an unknown route is+        //    REFUSED rather than silently treated as auto, because a typo that+        //    quietly measures something else is worse than no measurement.+        let route = args.get("route").and_then(|v| v.as_str()).unwrap_or("auto").to_ascii_lowercase();+        if !matches!(route.as_str(), "auto" | "loadfile4" | "loadfile2" | "opendoc6") {+            return Err(HostError::coded(+                "unknown_import_route",+                format!(+                    "unknown import route {route:?} — expected \"auto\", \"loadfile4\", \+                     \"loadfile2\" or \"opendoc6\""+                ),+            ));+        }+        // F2 — DISSOLVE IS REFUSED BEFORE ANY ROUTE IS CHOSEN. It used to live+        // inside the interconnect route only, so asking for it while the toggle+        // was off (or unreadable) SILENTLY DROPPED it: the caller asked for an+        // unlinked import, got a linked-or-not import with no answer either way,+        // and nothing said so. A request this bridge will not honour must fail+        // closed on every route.+        if args.get("dissolve").and_then(|v| v.as_bool()).unwrap_or(false) {+            return Err(Self::dissolve_refusal());+        }         let mut errors = 0i32;         // Count first. LoadFile4 can IMPORT SUCCESSFULLY and still hand back         // something that is not an IDispatch we can use — and the first version@@ -2953,16 +3827,110 @@ impl ComBackend {                 ))             }         };+        // ── OPENDOC6 ONLY WHEN ASKED FOR BY NAME. `auto` no longer prefers it+        //    under 3D Interconnect: the matrix measured that route returning+        //    `errors=2097152` (swFileRequiresRepairError) on every option value+        //    tried, so preferring it would be preferring a known failure.+        if route == "opendoc6" {+            return self.import_via_opendoc6(&sw, path, before, interconnect, diagnostics, args);+        }+        // ── LOADFILE2 ALONE, for the matrix: no LoadFile4 first, so its answer+        //    is its own rather than a fallback's.+        if route == "loadfile2" {+            return self.import_via_loadfile2(&sw, path, before, interconnect, diagnostics);+        }++        // ── THE TRANSLATOR ROUTE.+        // ONE CALL, WITH THE ENCODING THE LIVE FAILURE NAMED. `importData` is an+        // `[in]` OBJECT parameter, and "no import data" is a NULL DISPATCH, not+        // VT_EMPTY — measured 2026-09-17 as+        // `Type mismatch. (0x80020005) (argument index 1)`, which with the+        // reversed `rgvarg` packing is this third parameter exactly.+        //+        // Deliberately NOT retried with the old encoding on failure: a silent+        // second attempt would make the next live run ambiguous about which+        // spelling SOLIDWORKS accepted, and this bridge has spent two releases+        // learning that a guess nobody can see is worse than a failure somebody+        // can read.+        // THE SECOND ARGUMENT, overridable for the matrix. "r" is what every+        // published example passes and what this bridge has always used; nothing+        // has measured whether it is right on this edition.+        let arg_string = args.get("argString").and_then(|v| v.as_str()).unwrap_or("r").to_string();+        // AND THE IMPORT DATA. `null_dispatch` is Nothing — correct as of 0.7.9,+        // and still not enough to make the call work here. `withImportData:true`+        // asks SOLIDWORKS for a real `ImportFileData` object with+        // `GetImportFileData(path)` and passes THAT, which is the other thing the+        // signature will take. What that call returns is reported rather than+        // assumed: an object, nothing, or a failure.+        let want_data = args.get("withImportData").and_then(|v| v.as_bool()).unwrap_or(false);+        let mut import_data_report = Value::Null;+        let import_data = if want_data {+            match sw.call("GetImportFileData", &[&Var::bstr(path)]) {+                Ok(v) => {+                    let d = v.as_dispatch().map(Disp);+                    import_data_report = json!({+                        "returnedObject": d.is_some(),+                        // The type name where SOLIDWORKS will give one. It is a+                        // read, so a failure here is reported as null rather than+                        // failing the import.+                        "typeName": d.as_ref().and_then(|o| o.string("GetType", &[]))+                            .map(Value::from).unwrap_or(Value::Null),+                    });+                    d+                }+                Err(e) => {+                    import_data_report = json!({+                        "returnedObject": false,+                        "failure": {"message": e.message, "detail": e.detail},+                    });+                    None+                }+            }+        } else {+            None+        };+        let data_arg = match &import_data {+            Some(d) => Var::dispatch(&d.0),+            None => Var::null_dispatch(),+        };         let load4 = sw.call(             "LoadFile4",             &[                 &Var::bstr(path),-                &Var::bstr("r"),-                &Var::empty(),+                &Var::bstr(&arg_string),+                &data_arg,                 &Var::byref_i4(&mut errors as *mut i32),             ],         );-        let load4_err = load4.as_ref().err().map(|e| e.message.clone());+        // THE EXACT CALL THAT WAS MADE, echoed on every reply. A matrix of+        // results is only readable if each row states its own invocation.+        let invocation = json!({+            "method": "LoadFile4",+            "args": {+                "path": path,+                "argString": arg_string,+                "importData": if import_data.is_some() { "GetImportFileData(path)" } else { "Nothing (null VT_DISPATCH)" },+                "errors": "byref i4",+            },+            "importFileData": import_data_report,+        });+        // KEEP THE WHOLE FAILURE, not its first sentence. `variant.rs` puts the+        // HRESULT, the EXCEPINFO source and description and the failing argument+        // index onto `detail`; 0.7.7 kept only `message`, so a live run where+        // EVERY STEP import began failing reported "SOLIDWORKS rejected+        // 'LoadFile4'" and nothing a person could act on. The translator's own+        // number is the entire diagnosis.+        let load4_fail: Option<Value> = load4.as_ref().err().map(|e| {+            json!({+                "message": e.message,+                "detail": e.detail,+                "code": e.code,+            })+        });+        let load4_err = load4.as_ref().err().map(|e| match &e.detail {+            Some(d) if !d.trim().is_empty() => format!("{}: {}", e.message, d),+            _ => e.message.clone(),+        });         let imported = load4.ok().and_then(|v| v.as_dispatch()).map(Disp);         // OWN IT THE MOMENT IT EXISTS. Registration used to happen in the         // dispatcher, on the way out — so any failure AFTER the document was@@ -2996,6 +3964,10 @@ impl ComBackend {             return Ok(json!({                 "imported": Self::doc_info(&doc),                 "via": "LoadFile4",+                "invocation": invocation.clone(),+                "interconnect": interconnect,+                "interconnectSettingReadable": interconnect.is_some(),+                "linked": false,                 "errors": errors,                 "documentsOpened": net,                 "netCountOk": net == 1,@@ -3021,6 +3993,7 @@ impl ComBackend {             return Ok(json!({                 "imported": Self::doc_info(&doc),                 "via": "LoadFile4 (handle recovered from ActiveDoc)",+                "invocation": invocation.clone(),                 "titleMayBeAnotherConcurrentImport": true,                 "errors": errors,                 "documentsOpened": net,@@ -3029,22 +4002,160 @@ impl ComBackend {                 "_hint": format!("{}{}", import_open_hint(net), RECOVERED_HANDLE_CAVEAT),             }));         }-        let ok = sw.call("LoadFile2", &[&Var::bstr(path), &Var::bstr("")])?.as_bool().unwrap_or(false);+        // THE SECOND ROUTE'S OWN ANSWER IS ALSO REPORTED. "LoadFile2 refused" and+        // "LoadFile2 threw" are different failures, and the reply used to show+        // neither.+        let load2 = sw.call("LoadFile2", &[&Var::bstr(path), &Var::bstr("")]);+        let load2_fail: Option<Value> = load2.as_ref().err().map(|e| {+            json!({"message": e.message, "detail": e.detail, "code": e.code})+        });+        let ok = load2.as_ref().ok().and_then(|v| v.as_bool()).unwrap_or(false);+        // ── THE MEASURED DEAD END. On SOLIDWORKS Connected 2026 with+        //    `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic+        //    neutral-file import route: the matrix of 2026-09-17 tried LoadFile4+        //    with Nothing and with a real ImportFileData object (errors=1 both),+        //    LoadFile2 (false), and OpenDoc6 across four option values and two+        //    document types (2097152 = requires repair, 1024 = invalid file+        //    type). The imports that worked from 09-05 to 09-11 must have run+        //    with the toggle OFF.+        //+        //    So `auto` still makes ONE LoadFile4 attempt — a future edition that+        //    accepts it will simply work — and then says what is actually wrong,+        //    naming the setting, rather than reporting a translator failure that+        //    would send the reader looking for a missing translator.+        //+        //    THE SETTING IS THE USER'S. This bridge does not change it and will+        //    not offer to.+        // F3 — AN UNREADABLE SETTING IS NOT "OFF". `None` means SOLIDWORKS would+        // not answer; `Some(false)` means the user turned it off. They take the+        // same route and mean opposite things about how much to trust the result,+        // so a failure under an unreadable toggle is reported as ITS OWN+        // outcome rather than as a translator that is missing.+        if !ok && route == "auto" && interconnect.is_none() {+            let mut e = HostError::coded(+                "interconnect_setting_unreadable",+                format!(+                    "SOLIDWORKS could not import {path}, and this bridge could not read whether 3D \+                     Interconnect is on — `GetUserPreferenceToggle(691)` gave no answer. With that \+                     setting ON, no programmatic import route works on this edition (measured \+                     2026-09-17), so the most likely cause of this failure cannot be confirmed or \+                     ruled out. Nothing was opened and nothing was changed."+                ),+            );+            e.detail = Some(+                "Check Tools > Options > System Options > Import > \"Enable 3D Interconnect\". If \+                 it is on, turn it off and import again. This bridge does not change user \+                 settings. swx_status shows whether COM is answering at all."+                    .into(),+            );+            e.data = Some(json!({+                "interconnect": Value::Null,+                "interconnectSettingReadable": false,+                "invocation": invocation.clone(),+                "loadFile4": load4_fail,+                "loadFile2": {"returned": load2.as_ref().ok().and_then(|v| v.as_bool()), "failure": load2_fail},+                "errors": errors,+                // WHAT THE COUNT DID: whether a document was left behind is the+                // fact ownership turns on. Named as the SUCCESS reply names them,+                // so a caller reads one vocabulary either way.+                "documentCountBefore": before,+                "documentCount": Self::document_count(&sw),+                "documentsOpened": Self::document_count(&sw).map(|a| a - before),+                "path": path,+            }));+            return Err(e);+        }+        if !ok && route == "auto" && interconnect == Some(true) {+            // "NOTHING WAS OPENED" MUST BE MEASURED AFTER THE LAST CALL THAT+            // COULD OPEN SOMETHING. The count behind this claim was taken before+            // LoadFile2 ran, so a LoadFile2 that returned false while still+            // leaving a document behind would be reported as a no-op — and the+            // document would be open, unowned and unmentioned.+            // Measured FIRST, so the sentence can be true either way.+            let mut e = HostError::coded("interconnect_blocks_import", String::new());+            let appeared = self.claim_document_born_from_a_failure(&sw, before, &mut e);+            let opened_anyway = appeared.get("appeared").and_then(|v| v.as_bool()).unwrap_or(false);+            e.message = format!(+                "SOLIDWORKS cannot import {path} while 3D Interconnect is on. Measured on this \+                 edition 2026-09-17: with `swMultiCAD_Enable3DInterconnect` TRUE, no programmatic \+                 route imports a neutral file — LoadFile4 returns nothing (errors=1) with or \+                 without ImportFileData, LoadFile2 returns false, and OpenDoc6 answers \+                 swFileRequiresRepairError. {}",+                if opened_anyway {+                    "A document appeared ANYWAY and this bridge has taken ownership of it — see \+                     `afterFailure.claimed`; it can be closed and drained."+                } else {+                    "Nothing was opened and nothing was changed."+                }+            );+            // ASK, DO NOT INSTRUCT. This used to read "Turn it off in+            // SOLIDWORKS: Tools > Options > …", which is a runbook — and an agent+            // holding a desktop bridge can execute a runbook. The setting belongs+            // to the user; the bridge's job is to say what would unblock the+            // import and let them decide, not to describe the keystrokes as+            // though somebody should perform them.+            e.detail = Some(format!(+                "ASK THE USER to turn 3D Interconnect off if they want this import (it lives at \+                 Tools > Options > System Options > Import > \"Enable 3D Interconnect\"), then \+                 try again — this bridge will route through LoadFile4 and say so. THIS BRIDGE \+                 DOES NOT CHANGE USER SETTINGS, not even temporarily: a crash between changing \+                 and restoring would leave SOLIDWORKS configured differently than they left it. \+                 (To measure the matrix again, swx_import takes \+                 route/openOptions/docType/withImportData/argString and echoes the exact \+                 invocation.)"+            ));+            e.data = Some(json!({+                "interconnect": true,+                "importDiagnosticsAutoRun": diagnostics,+                "afterFailure": appeared,+                "invocation": invocation.clone(),+                "loadFile4": load4_fail,+                "loadFile2": {"returned": load2.as_ref().ok().and_then(|v| v.as_bool()), "failure": load2_fail},+                "errors": errors,+                // WHAT THE COUNT DID: whether a document was left behind is the+                // fact ownership turns on. Named as the SUCCESS reply names them,+                // so a caller reads one vocabulary either way.+                "documentCountBefore": before,+                "documentCount": Self::document_count(&sw),+                "documentsOpened": Self::document_count(&sw).map(|a| a - before),+                "path": path,+                "measured": "2026-09-17: LoadFile4 errors=1 (Nothing and ImportFileData),                              LoadFile2 false, OpenDoc6 docType 1 options 0/1/2/16 errors=2097152,                              docType 0 options 1 errors=1024",+                "userSettingPath": "Tools > Options > System Options > Import > Enable 3D Interconnect",+            }));+            return Err(e);+        }         if !ok {             return Err(HostError::detailed(                 format!(                     "SOLIDWORKS could not import {path} (LoadFile4 opened nothing, errors={errors}; \-                     LoadFile2 refused). Importable formats depend on the installed translators."+                     LoadFile2 {}). Importable formats depend on the installed translators.",+                    if load2_fail.is_some() { "failed" } else { "refused" }                 ),                 load4_err.unwrap_or_else(|| "LoadFile4 returned no document".into()),-            ));+            )+            .with_data(json!({+                "invocation": invocation.clone(),+                "interconnect": interconnect,+                "importDiagnosticsAutoRun": diagnostics,+                "loadFile4": load4_fail,+                "loadFile2": {+                    "returned": load2.as_ref().ok().and_then(|v| v.as_bool()),+                    "failure": load2_fail,+                },+                "errors": errors,+                "path": path,+            })));         }         let doc = sw             .obj("ActiveDoc", &[])?             .ok_or_else(|| HostError::msg("import reported success but no document became active"))?;         Ok(json!({             "imported": Self::doc_info(&doc),-            "via": "LoadFile2",+            "via": "LoadFile2 (after LoadFile4)",+            "invocation": invocation.clone(),+            "interconnect": interconnect,+            "interconnectSettingReadable": interconnect.is_some(),+            "linked": false,             "errors": errors,             "documentsOpened": 1,         }))
src/comhost/expr.rs+11−5
@@ -28,11 +28,17 @@ pub enum Literal {     Int(i32),     Num(f64),     Bool(bool),-    /// `null` / `nothing` — an EMPTY variant, which is how a late-bound caller-    /// passes "omitted" for an optional `[in]` object parameter (SOLIDWORKS-    /// uses those for callout/import-data arguments). Discovered while probing-    /// the live API: without it, half the interesting signatures are unreachable-    /// from the escape hatch.+    /// `null` / `nothing` — a NULL DISPATCH variant (`VT_DISPATCH` with a null+    /// `pdispVal`), which is how a late-bound caller passes `Nothing` for an+    /// `[in]` object parameter. SOLIDWORKS uses those for callout and+    /// import-data arguments; without it, half the interesting signatures are+    /// unreachable from the escape hatch.+    ///+    /// **This used to say VT_EMPTY, and that was wrong.** Measured live+    /// 2026-09-17: `LoadFile4`'s `importData` passed as `VT_EMPTY` answers+    /// `Type mismatch. (0x80020005)` on that argument. "Omitted" and "an object+    /// that is Nothing" are different things to IDispatch, and the documented use+    /// here — `SelectByID2`'s callout parameter — is the second kind.     Null, } 
src/comhost/host.rs+220−6
@@ -30,6 +30,19 @@ pub struct HostError {     /// server's. Carried over the wire so the server's `hung` verdict never has     /// to guess a timeout from the wording of a message.     pub timed_out: bool,+    /// WHAT THE FAILING CALL ACTUALLY SAID, structured.+    ///+    /// Live, 2026-09-17: every STEP import began failing with `LoadFile4` raising+    /// a COM exception, and the reply the caller saw was+    /// *"SOLIDWORKS could not import … (LoadFile4 opened nothing, errors=0;+    /// LoadFile2 refused)"* with the detail *"SOLIDWORKS rejected 'LoadFile4'"*.+    /// The HRESULT, the EXCEPINFO source and description and the argument index+    /// — all of which `variant.rs` had already put on the inner error — were+    /// dropped on the floor, because the import kept only `e.message`. A caller+    /// cannot diagnose a translator from prose that omits the one number the+    /// translator returned. This field carries the structured remains of the+    /// inner failure through to the reply.+    pub data: Option<serde_json::Value>,     /// A DOCUMENT THIS CALL CREATED BEFORE IT FAILED.     ///     /// Live, 2026-09-11: an import whose com host timed out AFTER `LoadFile4`@@ -51,19 +64,25 @@ impl HostError {     }      pub fn msg(message: impl Into<String>) -> Self {-        Self { message: message.into(), detail: None, code: None, timed_out: false, created: None }+        Self { message: message.into(), detail: None, code: None, timed_out: false, created: None, data: None }     }      /// A call the HOST abandoned. One half of `host_app_hung`.     pub fn timeout(message: impl Into<String>) -> Self {-        Self { message: message.into(), detail: None, code: None, timed_out: true, created: None }+        Self { message: message.into(), detail: None, code: None, timed_out: true, created: None, data: None }     }     pub fn detailed(message: impl Into<String>, detail: impl Into<String>) -> Self {-        Self { message: message.into(), detail: Some(detail.into()), code: None, timed_out: false, created: None }+        Self { message: message.into(), detail: Some(detail.into()), code: None, timed_out: false, created: None, data: None }+    }++    /// Attach the structured remains of an inner failure.+    pub fn with_data(mut self, data: serde_json::Value) -> Self {+        self.data = Some(data);+        self     }     /// A failure a caller can branch on.     pub fn coded(code: &str, message: impl Into<String>) -> Self {-        Self { message: message.into(), detail: None, code: Some(code.to_string()), timed_out: false, created: None }+        Self { message: message.into(), detail: None, code: Some(code.to_string()), timed_out: false, created: None, data: None }     } } @@ -164,6 +183,20 @@ pub const READ_ONLY_COMMANDS: &[&str] = &[     //                        version closed a user's saved file with `CloseDoc`     //                        (no save prompt). See `is_import_temp`.     "close_import_temps",+    //   `preserve_and_close`  CLOSES EVERY OPEN DOCUMENT, including the user's,+    //                        and is on this list deliberately. Drew's standing+    //                        authorization of 2026-09-11 — "you MUST find/build a+    //                        way to safely close the docs/program" — makes the+    //                        CLOSE authorized. It does not make losing anything+    //                        authorized, and those are different permissions, so+    //                        the guard that replaces the opt-in is a SEQUENCE+    //                        rather than a question: every document is saved to+    //                        the recovery folder and verified on disk BEFORE+    //                        anything closes, and a document that could not be+    //                        saved is not closed. The user's own files are never+    //                        written over — a dirty document with a path is saved+    //                        into recovery, not over its original.+    "preserve_and_close",     //   `release_com` / `com_refs`  Drop or count the IDispatch pointers THIS     //                        HOST holds. They change nothing in SOLIDWORKS — a     //                        client releasing its own reference is not a write —@@ -260,6 +293,186 @@ pub const DOC_SAMPLE_BUDGET_SECS: u64 = 5; /// where they are rare it stops and reports what it scanned. pub const DRAIN_WALK_BUDGET_SECS: u64 = 20; +/// The smallest a RECOVERY FILE may be and still count as written.+///+/// 0.7.1 used 1 KB, and the comment above the check said why that was wrong: if+/// the smallest real SOLIDWORKS document is TENS of kilobytes, a 1 KB floor does+/// not separate a written file from a TRUNCATED one — only from an empty one. A+/// partial write on a full disk does not usually stop at zero bytes; it stops+/// somewhere in the middle, at 2 KB or 8 KB or 30 KB, and every one of those+/// passed a 1 KB floor and authorised a close.+///+/// 16 KB is MEASURED to be safe in the direction that matters. Across the 166+/// real documents in this container (see `SWDOC_FORMAT_MARKER`) the smallest of+/// any kind is a 29,130-byte part; the smallest assembly is 95,492 and the+/// smallest drawing 319,803. The floor must never reject a real document, so it+/// sits below the smallest one observed — with room for an empty new part, which+/// is smaller than anything in a repository of finished work.+///+/// This is the floor for a PART, and the weakest of the three: an assembly and a+/// drawing are held to more (`DocKind::min_recovery_bytes`), because the smallest+/// real one of each measured here is 95,492 and 319,803 bytes. It is the value a+/// file of unrecognised kind is held to, since it is the one that cannot reject a+/// genuine document of any kind.+///+/// It is deliberately not the whole check. The decisive test is structural — see+/// `recovery_file_verdict` — because a fragment can be any size.+pub const MIN_RECOVERY_BYTES: u64 = 24 * 1024;++/// The OLE Compound File (CFBF) magic. **Legacy SOLIDWORKS only.**+///+/// Pass 23 proposed this as THE structural check: "SOLIDWORKS documents are OLE+/// compound files, so a real one begins with D0 CF 11 E0 A1 B1 1A E1". That was+/// true of older SOLIDWORKS and is the answer the whole internet still gives, and+/// it is WRONG for the files this bridge will actually be handed — see+/// `SWDOC_FORMAT_MARKER`. It is kept because an older document opened from an+/// archive genuinely is a compound file, and accepting one costs nothing.+pub const OLE_COMPOUND_FILE_SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];++/// Bytes 4..8 of a modern SOLIDWORKS document. **Measured, not assumed.**+///+/// 2026-09-11, on 166 real `.SLDPRT` / `.SLDASM` / `.SLDDRW` files from three+/// independent hardware repositories in this container (`cosmiic-pg4-hardware`,+/// `cosmiic-bp2-hardware`, `cosmiic-pm-hardware`):+///+///   * **0 of 166** begin with the OLE compound-document signature. It does not+///     appear anywhere in the first 4 MB of any of them. Modern SOLIDWORKS does+///     not use CFBF.+///   * **166 of 166** carry `00 00 00 04` at offset 4. Bytes 0..4 differ per+///     file (they look like a per-document identifier); bytes 4..8 do not vary+///     across parts, assemblies, drawings, repositories or file sizes from 29 KB+///     to 37 MB.+///+/// Had the CFBF check shipped as specified, `recovery_file_verdict` would have+/// rejected EVERY document on a modern install — failing closed, so nothing would+/// have been lost, but the sweep would have preserved everything and closed+/// nothing, for ever. The verb Drew asked for would have been inert.+pub const SWDOC_FORMAT_MARKER: [u8; 4] = [0x00, 0x00, 0x00, 0x04];++/// Which container a recovery file turned out to be, if this bridge knows it.+pub fn recognised_container(head: &[u8]) -> Option<&'static str> {+    if head.len() >= 8 && head[..8] == OLE_COMPOUND_FILE_SIGNATURE {+        return Some("ole-compound");+    }+    if head.len() >= 8 && head[4..8] == SWDOC_FORMAT_MARKER {+        return Some("swdoc");+    }+    None+}++/// What a recovery file is, as far as this bridge can tell from the outside.+#[derive(Debug, Clone)]+pub struct RecoveryVerdict {+    /// Size on disk. `0` also covers "could not be measured" — see `why`.+    pub bytes: u64,+    /// Whether the head of the file identifies a container this bridge knows.+    pub signature_ok: bool,+    /// Which one, when it does: `"swdoc"` or `"ole-compound"`.+    pub container: Option<&'static str>,+    /// True only when this file may be closed against.+    pub ok: bool,+    /// Why not, in terms of the harm, when `ok` is false.+    pub why: Option<String>,+}++/// IS THIS FILE REALLY THE DOCUMENT? Asked before the document is closed, which+/// is the last moment anybody can ask.+///+/// It **fails closed** on purpose. Anything this cannot read, cannot measure, or+/// does not recognise is NOT preserved, and the document stays open. If a future+/// SOLIDWORKS changed container format, this check would start refusing to close+/// documents — visibly, with the signature it actually found in the message —+/// rather than closing them against files it cannot vouch for. Refusing to close+/// is a nuisance; closing wrongly loses the user's work, and those are not the+/// same size of mistake.+pub fn recovery_file_verdict(+    path: &str,+    kind: Option<crate::disk::DocKind>,+    original_bytes: Option<u64>,+) -> RecoveryVerdict {+    use std::io::Read;+    let refuse = |bytes: u64, why: String| RecoveryVerdict {+        bytes,+        signature_ok: false,+        container: None,+        ok: false,+        why: Some(why),+    };+    if path.trim().is_empty() {+        return refuse(0, "no recovery path to check".into());+    }+    let bytes = match std::fs::metadata(path) {+        Ok(m) => m.len(),+        Err(e) => return refuse(0, format!("the recovery file could not be measured: {e}")),+    };+    let mut head = [0u8; 8];+    let read = std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut head).map(|_| ()));+    if read.is_err() {+        return refuse(+            bytes,+            format!(+                "the first 8 bytes of the recovery file could not be read, so this bridge cannot \+                 tell whether it is a document at all — it was NOT closed"+            ),+        );+    }+    let Some(container) = recognised_container(&head) else {+        let found = head.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join(" ");+        return refuse(+            bytes,+            format!(+                "the recovery file does not begin like a SOLIDWORKS document (found {found}; \+                 expected 00 00 00 04 at offset 4, or the legacy OLE compound-document signature \+                 D0 CF 11 E0 A1 B1 1A E1 at offset 0), so it is not something this bridge can \+                 vouch for — the document was NOT closed"+            ),+        );+    };+    // PER KIND, because the three kinds are not the same size: the smallest real+    // part measured here is 29,130 bytes, the smallest assembly 95,492 and the+    // smallest drawing 319,803. One floor for all three would have to be the+    // lowest of them, which would let an assembly stop at a third of its size and+    // still pass.+    //+    // THE KIND IS PASSED IN, NOT READ OFF THE NAME. 0.7.4 called+    // `from_hint(path)` here, and the path is the one this sweep chose FROM THE+    // TYPE — so a single unreadable `GetType` named the file `.SLDPRT` and this+    // function then read "part" back out of it. The verification was asking the+    // question to its own answer, and a 30 KB truncated assembly cleared the+    // 24 KB part floor. A derived value cannot check the thing it was derived+    // from.+    let floor = match kind {+        Some(k) => k.min_recovery_bytes(),+        // UNKNOWN KIND TAKES THE HIGHEST FLOOR, and, when the document had a file+        // of its own, at least what that file weighed. Refusing to close is the+        // safe error: it is loud, it names the file and the number, and nothing+        // is lost. Closing against a file nobody could classify is not.+        None => crate::disk::DocKind::unknown_kind_floor().max(original_bytes.unwrap_or(0)),+    };+    if bytes < floor {+        return RecoveryVerdict {+            bytes,+            signature_ok: true,+            container: Some(container),+            ok: false,+            why: Some(match kind {+                Some(k) => format!(+                    "the recovery file holds {bytes} byte(s), below the {floor} this bridge \+                     requires of a real {k:?} — a truncated write is what a full disk produces, \+                     so the document was NOT closed"+                ),+                None => format!(+                    "the recovery file holds {bytes} byte(s) and this bridge could not establish \+                     what KIND of document it is, so it is held to the strictest floor it has \+                     ({floor}) — an unclassifiable document is not closed against an unchecked \+                     file"+                ),+            }),+        };+    }+    RecoveryVerdict { bytes, signature_ok: true, container: Some(container), ok: true, why: None }+}+ /// The resolved `%TEMP%` this process would use, lowercased with `\` separators /// and no trailing slash. `None` when the environment does not say. ///@@ -618,6 +831,7 @@ pub const COMMANDS: &[&str] = &[     "inspect_bodies",     "import",     "close_import_temps",+    "preserve_and_close",     // The quit ladder: one rung per call, plus the two reference-discipline     // commands the ladder's invariant depends on.     "quit_rung",@@ -804,7 +1018,7 @@ pub fn run() {             match verdict {                 Ok(Ok(())) => {}                 Ok(Err(e)) => {-                    send(&json!({"id": id, "ok": false, "error": e.message, "detail": e.detail, "errorCode": e.code, "timedOut": e.timed_out}));+                    send(&json!({"id": id, "ok": false, "error": e.message, "detail": e.detail, "errorCode": e.code, "timedOut": e.timed_out, "errorData": e.data}));                     continue;                 }                 Err(_) => {@@ -847,7 +1061,7 @@ pub fn run() {                 }                 send(                 &json!({"id": id, "ok": false, "error": e.message, "detail": e.detail,-                        "errorCode": e.code, "timedOut": e.timed_out,+                        "errorCode": e.code, "timedOut": e.timed_out, "errorData": e.data,                         "createdBeforeFailing": e.created}),                 )             }
src/comhost/mock.rs+710−7
@@ -80,6 +80,12 @@ struct MockDoc {     doc_type: &'static str,     config: String,     dirty: bool,+    /// A COMPONENT AN ASSEMBLY HOLDS OPEN. SOLIDWORKS keeps a referenced part+    /// open while its parent is, and lets it go when the parent closes — so a+    /// close loop that only counts the documents it named would leave the count+    /// wrong in both directions. Modelled because the safety claim is "the count+    /// reaches zero".+    held_open_by_assembly: bool,     props: Map<String, Value>,     material: Option<String>,     /// name -> value in METRES, mirroring IDimension::SystemValue.@@ -137,6 +143,10 @@ impl MockDoc {             doc_type,             config: v.get("config").and_then(|x| x.as_str()).unwrap_or("Default").to_string(),             dirty: v.get("dirty").and_then(|x| x.as_bool()).unwrap_or(false),+            held_open_by_assembly: v+                .get("heldOpenByAssembly")+                .and_then(|x| x.as_bool())+                .unwrap_or(false),             props: v.get("props").and_then(|x| x.as_object()).cloned().unwrap_or_default(),             material: v.get("material").and_then(|x| x.as_str()).map(String::from),             dimensions: v.get("dimensions").and_then(|x| x.as_object()).cloned().unwrap_or_default(),@@ -166,6 +176,7 @@ impl MockDoc {             doc_type,             config,             dirty: false,+            held_open_by_assembly: false,             props: Map::new(),             material: None,             dimensions: Map::new(),@@ -234,6 +245,22 @@ fn fault(name: &str) -> bool {     crate::scratch::root().join(name).exists() } +/// THE ORDER OF EVENTS, WRITTEN DOWN.+///+/// The safety property of the close sweep is not a fact about the final state —+/// a session can end with every document on disk and still have closed one of+/// them before it was written. It is a fact about ORDER, and order is only+/// checkable if it is recorded. Every recovery write and every close appends a+/// line here, so a test can assert what happened WHEN rather than what was true+/// at the end.+fn journal(event: &str, path: &str) {+    use std::io::Write;+    let f = crate::scratch::root().join("mock-close-order.log");+    if let Ok(mut h) = std::fs::OpenOptions::new().create(true).append(true).open(f) {+        let _ = writeln!(h, "{event}\t{path}");+    }+}+ const MOCK_VERSION: &str = "mock 33.0.0 (2025 SP0)";  /// The `%TEMP%` the SIMULATED launcher writes under.@@ -429,7 +456,40 @@ impl MockBackend {             return true;         }         let Some(pos) = self.find_by_name(name) else { return false };+        // A COMPONENT CANNOT CLOSE WHILE ITS ASSEMBLY HOLDS IT OPEN. This is why+        // the sweep closes parents before children at all; modelling it as a+        // REFUSAL rather than a silent success is what makes the ordering+        // testable — reverse the sort and this returns false, the document is+        // reported as a close failure, and `done` goes false.+        if self.docs[pos].held_open_by_assembly+            && self.docs.iter().any(|x| x.doc_type == "assembly")+        {+            // ON THE RECORD. A refusal that left no trace made the ordering+            // untestable: close the children first and the sweep still finishes,+            // because it retries and the parent eventually goes. "It got there in+            // the end" is not the property — asking in an order SOLIDWORKS can+            // answer is, and only a written-down refusal can show it was violated.+            journal("refused", &self.docs[pos].path);+            return false;+        }         let d = self.docs.remove(pos);+        // THIS document closes FIRST, then whatever it was holding: the parent is+        // what the sweep asked to close, and the children go out with it.+        journal("closed", &d.path);+        // CLOSING AN ASSEMBLY RELEASES WHAT IT HELD, exactly as SOLIDWORKS does:+        // the components go with their parent, and they go on the record.+        if d.doc_type == "assembly" {+            let released: Vec<String> = self+                .docs+                .iter()+                .filter(|x| x.held_open_by_assembly)+                .map(|x| x.path.clone())+                .collect();+            for p in &released {+                journal("closed", p);+            }+            self.docs.retain(|x| !x.held_open_by_assembly);+        }         self.created.retain(|(t, _)| t != &d.title);         if self.active.as_deref() == Some(d.path.as_str()) {             self.active = self.docs.first().map(|x| x.path.clone());@@ -723,6 +783,22 @@ impl MockBackend {         // property, it is a bug — Drew authorized closing his own documents and         // the bridge told him no.         let env_on = crate::writes::user_writes_enabled();+        // DREW'S STANDING AUTHORIZATION for ENDING THE SESSION, honoured by the+        // host's own veto so the two layers agree. `userAuthorized:true` with a+        // reason grants the quit and nothing else — see writes::authorize. The+        // host still checks it for itself rather than trusting the server's word,+        // which is the point of being an independent veto.+        let authorized_quit = always+            && args.get("userAuthorized").and_then(|v| v.as_bool()).unwrap_or(false)+            && args+                .get("reason")+                .and_then(|v| v.as_str())+                .map(|r| r.trim().len() >= 8)+                .unwrap_or(false);+        if authorized_quit {+            return Ok(());+        }+         let missing = crate::writes::missing_opt_in(args);         if env_on && missing.is_empty() {             return Ok(());@@ -1334,6 +1410,349 @@ impl MockBackend {              "com_refs" => Ok(json!({"outstanding": 0})), +            // DREW'S STANDING AUTHORIZATION, modelled: preserve every document+            // to disk, THEN close it. The property under test is a SEQUENCE —+            // nothing closes before it is on disk — so the mock writes real files+            // and refuses to close a document whose file is not there.+            // ONE DOCUMENT PER CALL. The server censuses for a dialog between+            // every save and every close, so the host does one step at a time —+            // see `ComBackend::cmd_preserve_and_close` for why a batch was the+            // wrong unit.+            "preserve_and_close" => {+                self.require_running()?;+                let phase = args.get("phase").and_then(|v| v.as_str()).unwrap_or("preserve");+                let count = mock_doc_count(self.docs.len());+                if phase == "close" {+                    let path = args+                        .get("path")+                        .and_then(|v| v.as_str())+                        .filter(|s| !s.trim().is_empty())+                        .ok_or_else(|| HostError::msg("path is required"))?+                        .to_string();+                    let written_here =+                        args.get("writtenHere").and_then(|v| v.as_bool()).unwrap_or(false);+                    // NOTHING CLOSES THAT IS NOT ON DISK — re-verified at the+                    // moment of the close, not merely at the moment of the save,+                    // and through the SAME verdict the real backend uses.+                    if written_here {+                        // THE KIND TRAVELS WITH THE DOCUMENT — re-deriving it+                        // from the name would ask the question to its own answer.+                        let kind = args+                            .get("docKind")+                            .and_then(|v| v.as_str())+                            .and_then(|t| super::super::disk::DocKind::from_type_name(t));+                        let original_bytes = args.get("originalBytes").and_then(|v| v.as_u64());+                        let v = super::host::recovery_file_verdict(&path, kind, original_bytes);+                        if !v.ok {+                            return Ok(json!({+                                "phase": "close", "path": path, "closed": false,+                                "documentCount": count,+                                "why": v.why.unwrap_or_else(|| "unverifiable".into()),+                            }));+                        }+                    }+                    // A CLOSE CAN RAISE A PROMPT TOO. This fault puts one on+                    // screen as the first document closes, so the census the+                    // server runs before the NEXT close is the only thing that+                    // can catch it.+                    if fault("mock-dialog-during-close") {+                        if let Some(d) = fake_dir() {+                            let _ = std::fs::write(+                                d.join("dialog-open.json"),+                                json!({+                                    "hwnd": 9102, "className": "#32770",+                                    "title": "Rebuild before closing?", "visible": true+                                })+                                .to_string(),+                            );+                        }+                    }+                    // ALREADY GONE WITH ITS PARENT: the assembly that held this+                    // component closed it on the way out. That is a close, and it+                    // happened after this document's own recovery write.+                    let closed = if self.find_by_name(&path).is_none() {+                        true+                    } else {+                        self.close_doc_by_name(&path) && self.find_by_name(&path).is_none()+                    };+                    let after = mock_doc_count(self.docs.len());+                    self.publish_session();+                    return Ok(json!({+                        "phase": "close", "path": path, "closed": closed,+                        "documentCount": after,+                        "why": if closed { Value::Null } else { json!("still open after CloseDoc") },+                    }));+                }+                if phase != "preserve" {+                    return Err(HostError::msg(format!(+                        "unknown preserve_and_close phase {phase:?}"+                    )));+                }+                let recovery = args+                    .get("recoveryDir")+                    .and_then(|v| v.as_str())+                    .filter(|s| !s.is_empty())+                    .ok_or_else(|| HostError::msg("recoveryDir is required"))?+                    .to_string();+                // A HOST FAILURE THAT CARRIES REAL COM DETAIL, mid-sweep.+                //+                // `mock-com-timeout` produces a SidecarError with no `data`, so a+                // test using it cannot tell `hostError: null` (the host sent+                // nothing) from the field being ABSENT because nothing constructs+                // it. This fault gives the failure the shape a live COM exception+                // has — HRESULT, source, description — so the structured path is+                // provable end to end.+                if fault("mock-preserve-host-error-with-data") {+                    let mut e = HostError::detailed(+                        "SOLIDWORKS rejected 'SaveAs3' while the sweep was preserving a document",+                        "Type mismatch. (0x80020005) [SldWorks] (argument index 1)",+                    );+                    e.data = Some(json!({+                        "call": "SaveAs3",+                        "hresult": "0x80020005",+                        "source": "SldWorks",+                        "description": "Type mismatch.",+                        "argumentIndex": 1,+                    }));+                    return Err(e);+                }+                let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+                let _ = std::fs::create_dir_all(&recovery);+                // A TYPE THE BACKEND CANNOT READ. On the real backend this is+                // `GetType` failing on a document being closed under memory+                // pressure — the condition the sweep exists for. It must become+                // UNKNOWN, never a part.+                let type_unreadable = fault("mock-doctype-unreadable");+                let Some((title, dirty, doc_type, reported)) = self.docs.get(index).map(|d| {+                    (d.title.clone(), d.dirty, d.doc_type, Self::reported_path(d).to_string())+                }) else {+                    return Ok(json!({+                        "phase": "preserve", "index": index, "walked": 0,+                        "documentCount": count, "record": Value::Null, "ready": Value::Null,+                    }));+                };+                let pathless = reported.trim().is_empty();+                // The same chain the real backend walks: the type, then the+                // document's OWN path, then its title. Neither of the last two is+                // a name this sweep produced.+                let kind = if type_unreadable {+                    None+                } else {+                    super::super::disk::DocKind::from_type_name(doc_type)+                }+                .or_else(|| super::super::disk::DocKind::from_extension(&reported))+                .or_else(|| super::super::disk::DocKind::from_extension(&title));+                let kind_name = kind.map(|k| json!(k.type_name())).unwrap_or(Value::Null);+                // The original's size, read before anything is written.+                let original_bytes =+                    if pathless { None } else { std::fs::metadata(&reported).ok().map(|m| m.len()) };+                if !dirty && !pathless {+                    // CLOSED IN PLACE: the same measurement the real backend+                    // takes, so the record can later say whether the user's own+                    // file is still what the sweep closed it against.+                    let original_modified_ms = crate::scratch::modified_ms(&reported);+                    return Ok(json!({+                        "phase": "preserve", "index": index, "walked": 1,+                        "documentCount": count,+                        "record": json!({+                            "title": title, "path": reported, "recoveryPath": Value::Null,+                            "recoveryBytes": Value::Null, "recoverySignatureOk": Value::Null,+                            "docKind": kind_name, "originalBytes": original_bytes,+                            "originalModifiedMs": original_modified_ms,+                            "wasDirty": false, "wasPathless": false, "preserved": true,+                        }),+                        // Already SOLIDWORKS' own file: on disk by definition in+                        // the simulated world, and its path is a Windows one that+                        // does not exist on the host running the suite.+                        "ready": json!({+                            "path": reported, "docType": kind_name, "writtenHere": false,+                            "originalBytes": Value::Null,+                        }),+                    }));+                }+                // A UNIQUE FILE, actually written: several open documents can+                // share a title, and each must get its own.+                // NEVER A GUESSED EXTENSION. An unknown kind takes the title's+                // own extension if it has one, else a neutral name that claims+                // nothing — the downstream verdict must not be able to read a+                // guess back out of the file name.+                // SOLIDWORKS MAY REFUSE THE NEUTRAL NAME. `SaveAs3` dispatches+                // its writer on the extension and `.sldrecover` matches none, so+                // the simulator can be told to reject it — and the sweep must+                // then re-save as `.SLDPRT` and say that it did, WITHOUT the+                // verification softening: the kind is still unknown.+                // MEASURED LIVE 2026-09-17: SaveAs3 to `.sldrecover` answers+                // "format not supported" and writes nothing, so an unknown-kind+                // document is named as a part up front and the RECORD carries the+                // fact that the name is a guess. The simulator models the same+                // decision, and the fault below models the world in which a+                // neutral name IS attempted and refused — the path kept for a+                // future version that accepts it.+                let mut named_as_part = true;+                let ext = match kind {+                    Some(k) => {+                        named_as_part = false;+                        k.extension()+                    }+                    None if fault("mock-neutral-extension-accepted")+                        && !fault("mock-saveas-rejects-unknown-extension") =>+                    {+                        named_as_part = false;+                        "sldrecover"+                    }+                    None => super::super::disk::DocKind::Part.extension(),+                };+                let stem: String = title+                    .rsplit_once('.')+                    .map(|(a, _)| a)+                    .unwrap_or(&title)+                    .chars()+                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })+                    .collect();+                let stem = if stem.trim_matches('_').is_empty() { "document" } else { stem.trim_matches('_') };+                let mut out = format!("{recovery}/{stem}.{ext}");+                let mut n = 1;+                while std::path::Path::new(&out).exists() {+                    out = format!("{recovery}/{stem}-{n}.{ext}");+                    n += 1;+                }+                let fail = |why: String, bytes: u64, sig: bool| -> Value {+                    json!({+                        "phase": "preserve", "index": index, "walked": 1,+                        "documentCount": count,+                        "record": json!({+                            "title": title, "path": reported,+                            "recoveryPath": if bytes == 0 && !sig && why.starts_with("simulated") {+                                Value::Null+                            } else {+                                json!(out)+                            },+                            "recoveryBytes": bytes, "recoverySignatureOk": sig,+                            "docKind": kind_name, "originalBytes": original_bytes,+                            "namingFallback": if named_as_part { json!("unknownKindNamedAsPart") } else { Value::Null },+                            "wasDirty": dirty, "wasPathless": pathless, "preserved": false,+                            "why": why,+                        }),+                        "ready": Value::Null,+                    })+                };+                if fault("mock-saveas-fails") {+                    return Ok(fail(+                        "simulated: SaveAs3 failed, so this document was NOT closed".into(),+                        0,+                        false,+                    ));+                }+                // WHAT A REAL DOCUMENT LOOKS LIKE, and the three ways a failing+                // disk makes something that is not one:+                //   * `mock-recovery-partial-write` — the entry exists, empty.+                //     `Path::exists()` says yes to it.+                //   * `mock-recovery-truncated` — a genuine header and then the+                //     write stops. Nonzero, plausible, and not the document. This+                //     is the case a byte floor alone was chosen badly for.+                //   * `mock-recovery-bad-header` — full size, wrong container.+                //     What a format change, or a file written by something else,+                //     would look like.+                // A REAL HEADER, measured from real files: four bytes that vary+                // per document, then the format marker every one of the 166+                // documents surveyed carries at offset 4. The simulator writes+                // what SOLIDWORKS writes, not what the internet says it writes —+                // modelling this as a CFBF file would have hidden the fact that+                // modern SOLIDWORKS is not a compound file at all.+                let mut header = vec![0x9f, 0xe4, 0x18, 0x9f];+                header.extend_from_slice(&super::host::SWDOC_FORMAT_MARKER);+                // A DOCUMENT OF THIS KIND'S SIZE. The floors differ per kind+                // because the real files do (the smallest assembly measured is+                // three times the smallest part, the smallest drawing eleven+                // times), so a simulator that wrote one size for all three would+                // have had assemblies failing their own floor.+                let full = match kind {+                    Some(super::super::disk::DocKind::Assembly) => 128 * 1024,+                    Some(super::super::disk::DocKind::Drawing) => 512 * 1024,+                    Some(super::super::disk::DocKind::Part) => 32 * 1024,+                    // A document whose kind is unknown is written at a size that+                    // would satisfy the strictest floor — the simulator models a+                    // COMPLETE write; it is the FAULTS that model incomplete ones.+                    None => 512 * 1024,+                };+                let payload: Vec<u8> = if fault("mock-recovery-partial-write") {+                    Vec::new()+                } else if fault("mock-recovery-truncated") {+                    let mut v = header.clone();+                    v.resize(2048, b'x');+                    v+                } else if fault("mock-recovery-bad-header") {+                    vec![b'x'; full]+                } else if fault("mock-recovery-legacy-container") {+                    // An older document, opened from an archive: genuinely a+                    // compound file, and genuinely closable.+                    let mut v = super::host::OLE_COMPOUND_FILE_SIGNATURE.to_vec();+                    v.resize(full, b'x');+                    v+                } else {+                    let mut v = header.clone();+                    v.resize(full, b'x');+                    v+                };+                if std::fs::write(&out, &payload).is_err() {+                    return Ok(fail("the recovery file could not be written".into(), 0, false));+                }+                // THE SAME VERDICT THE REAL BACKEND APPLIES, through the same+                // function: signature first, then size, failing closed.+                let v = super::host::recovery_file_verdict(&out, kind, original_bytes);+                // A SAVE CAN RAISE A PROMPT — a missing external reference, a+                // rebuild question. This fault puts one on screen the instant the+                // first document has been written, which is precisely the moment+                // a once-per-batch census could not see it.+                if fault("mock-dialog-after-save") {+                    if let Some(d) = fake_dir() {+                        let _ = std::fs::write(+                            d.join("dialog-open.json"),+                            json!({+                                "hwnd": 9101, "className": "#32770",+                                "title": "Save external references?", "visible": true+                            })+                            .to_string(),+                        );+                    }+                }+                if !v.ok {+                    return Ok(fail(+                        v.why.unwrap_or_else(|| "unverifiable".into()),+                        v.bytes,+                        v.signature_ok,+                    ));+                }+                journal("preserved", &out);+                // 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.+                {+                    let d = &mut self.docs[index];+                    d.saved = true;+                    d.dirty = false;+                    d.temp_path = None;+                    d.path = out.clone();+                    d.title = crate::comhost::mock::base_name(&out).to_string();+                }+                self.publish_session();+                Ok(json!({+                    "phase": "preserve", "index": index, "walked": 1,+                    "documentCount": count,+                    "record": json!({+                        "title": title, "path": reported, "recoveryPath": out,+                        "recoveryBytes": v.bytes, "recoverySignatureOk": true,+                        "docKind": kind_name, "originalBytes": original_bytes,+                        "namingFallback": if named_as_part { json!("unknownKindNamedAsPart") } else { Value::Null },+                        "wasDirty": dirty, "wasPathless": pathless, "preserved": true,+                    }),+                    "ready": json!({+                        "path": out, "docType": kind_name, "writtenHere": true,+                        "originalBytes": original_bytes,+                    }),+                }))+            }+             "close_import_temps" => {                 self.require_running()?;                 let max = args.get("max").and_then(|v| v.as_u64()).unwrap_or(200).max(1) as usize;@@ -1437,8 +1856,8 @@ impl MockBackend {                     })                     .collect();                 let mut closed = Vec::new();-                let mut wrong_document_closed = false;                 let mut close_failed: Vec<Value> = Vec::new();+                let mut wrong_document_closed = false;                 let mut close_ignored_path = false;                 let mut close_outcome_unknown = false;                 for (title, path) in &victims {@@ -2133,6 +2552,251 @@ impl MockBackend {                     .filter(|s| !s.is_empty())                     .ok_or_else(|| HostError::msg("filePath is required"))?                     .to_string();+                // EVERY STEP IMPORT FAILING, with the translator's own answer.+                // Live, 2026-09-17: LoadFile4 raised a COM exception on every+                // STEP and the reply carried only "SOLIDWORKS rejected+                // 'LoadFile4'" — the HRESULT and EXCEPINFO were dropped between+                // the COM layer and the import verb. This fault models the+                // failure INCLUDING the structured remains, so the wiring that+                // carries them to the caller is tested rather than assumed.+                // THE USER'S 3D INTERCONNECT SETTING, modelled because it decides+                // WHICH ROUTE the import takes — and because the live install has+                // it ON, which is the default. Measured 2026-09-17: toggle 691 is+                // TRUE on Drew's machine, so the simulator's default matches the+                // machine this bridge actually runs on, and the translator route+                // is what a fault turns on.+                // F3 — THREE STATES, NOT TWO. `None` is "SOLIDWORKS would not+                // answer"; `Some(false)` is "the user turned it off". They take+                // the same route and mean opposite things about how much to+                // trust a failure.+                let readable = !fault("mock-interconnect-toggle-unreadable");+                let interconnect = readable && !fault("mock-3d-interconnect-off");+                // ── THE MEASUREMENT SURFACE, ECHOED AND NOT INTERPRETED.+                //+                // The simulator has no SOLIDWORKS to ask, so it makes NO claim+                // about what any of these produce — that is the entire point of+                // the exercise. It validates the route name (a typo that quietly+                // measures something else is worse than no measurement) and+                // reports the invocation back, so the shape of a matrix row is+                // testable here and only the ANSWERS have to come from the live+                // application.+                let route = args+                    .get("route")+                    .and_then(|v| v.as_str())+                    .unwrap_or("auto")+                    .to_ascii_lowercase();+                if !matches!(route.as_str(), "auto" | "loadfile4" | "loadfile2" | "opendoc6") {+                    return Err(HostError::coded(+                        "unknown_import_route",+                        format!(+                            "unknown import route {route:?} — expected \"auto\", \"loadfile4\", \+                             \"loadfile2\" or \"opendoc6\""+                        ),+                    ));+                }+                let open_options = args.get("openOptions").and_then(|v| v.as_i64()).unwrap_or(1);+                let doc_type_arg = args.get("docType").and_then(|v| v.as_i64()).unwrap_or(1);+                let arg_string =+                    args.get("argString").and_then(|v| v.as_str()).unwrap_or("r").to_string();+                let with_data = args.get("withImportData").and_then(|v| v.as_bool()).unwrap_or(false);+                // THE MEASURED DEAD END, modelled: with 3D Interconnect on, the+                // `auto` route makes one LoadFile4 attempt and then refuses by+                // NAMING THE SETTING. The simulator's default has the toggle on,+                // matching the live machine, so this fault is what turns the+                // measured refusal on — the simulator has no SOLIDWORKS to fail+                // for it.+                if route == "auto" && !readable && fault("mock-interconnect-blocks-import") {+                    let mut e = HostError::coded(+                        "interconnect_setting_unreadable",+                        format!(+                            "SOLIDWORKS could not import {path}, and this bridge could not read \+                             whether 3D Interconnect is on. With that setting ON no programmatic \+                             import route works on this edition, so the most likely cause cannot \+                             be confirmed or ruled out. Nothing was opened and nothing was changed."+                        ),+                    );+                    e.detail = Some(+                        "Check Tools > Options > System Options > Import > \"Enable 3D \+                         Interconnect\". This bridge does not change user settings."+                            .into(),+                    );+                    e.data = Some(json!({+                        "interconnect": Value::Null,+                        "interconnectSettingReadable": false,+                        "path": path,+                    }));+                    return Err(e);+                }+                if route == "auto" && interconnect && fault("mock-interconnect-blocks-import") {+                    let mut e = HostError::coded(+                        "interconnect_blocks_import",+                        format!(+                            "SOLIDWORKS cannot import {path} while 3D Interconnect is on. \+                             Measured on this edition 2026-09-17: no programmatic route imports a \+                             neutral file with the toggle TRUE. Nothing was opened and nothing was \+                             changed."+                        ),+                    );+                    e.detail = Some(+                        "ASK THE USER to turn 3D Interconnect off if they want this import (it \+                         lives at Tools > Options > System Options > Import > \"Enable 3D \+                         Interconnect\"), then try again. THIS BRIDGE DOES NOT CHANGE USER \+                         SETTINGS, not even temporarily."+                            .into(),+                    );+                    e.data = Some(json!({+                        "interconnect": true,+                        "userSettingPath": "Tools > Options > System Options > Import > Enable 3D Interconnect",+                        "path": path,+                    }));+                    return Err(e);+                }+                let effective = if route == "auto" {+                    // `auto` attempts the TRANSLATOR even under 3D Interconnect:+                    // OpenDoc6 is a measured failure there, so preferring it+                    // would be preferring a known dead end.+                    "loadfile4"+                } else {+                    route.as_str()+                };+                let invocation = match effective {+                    "opendoc6" => json!({+                        "method": "OpenDoc6",+                        "args": {+                            "path": path, "docType": doc_type_arg, "options": open_options,+                            "configuration": "", "errors": "byref i4", "warnings": "byref i4",+                        },+                    }),+                    "loadfile2" => json!({"method": "LoadFile2", "args": {"path": path, "arg": ""}}),+                    _ => json!({+                        "method": "LoadFile4",+                        "args": {+                            "path": path, "argString": arg_string,+                            "importData": if with_data {+                                "GetImportFileData(path)"+                            } else {+                                "Nothing (null VT_DISPATCH)"+                            },+                            "errors": "byref i4",+                        },+                        // The simulator has no import-data object to hand back and+                        // says so rather than inventing one.+                        "importFileData": if with_data {+                            json!({"returnedObject": Value::Null, "simulated": true})+                        } else {+                            Value::Null+                        },+                    }),+                };+                // F2 — REFUSED BEFORE ANY ROUTE IS CHOSEN. Inside the+                // interconnect branch it was silently dropped whenever the+                // toggle was off or unreadable: the caller asked for an unlinked+                // import and got no answer either way.+                if fault("mock-dissolve-requested-without-measurement")+                    || args.get("dissolve").and_then(|v| v.as_bool()).unwrap_or(false)+                {+                    let mut e = HostError::coded(+                        "dissolve_not_measured",+                        "this bridge will not break a 3D Interconnect link with an API it has not \+                         verified on this install. The import itself went ahead as a LINKED part \+                         (that is what 3D Interconnect does); nothing was changed.",+                    );+                    e.detail = Some(+                        "measure which call breaks the link on this edition and verify the EFFECT, \+                         not the return value"+                            .into(),+                    );+                    return Err(e);+                }+                // LOADFILE4 UNDER 3D INTERCONNECT: it executes and returns+                // nothing, with errors=1 (swGenericError). Measured live+                // 2026-09-17 after 0.7.9 fixed the variant encoding. A bridge+                // that routed to the translator with the toggle ON would produce+                // exactly this, so the simulator can produce it too.+                if interconnect && fault("mock-import-translator-under-interconnect") {+                    return Err(HostError::detailed(+                        format!(+                            "SOLIDWORKS could not import {path} (LoadFile4 opened nothing, \+                             errors=1; LoadFile2 refused). Importable formats depend on the \+                             installed translators."+                        ),+                        "LoadFile4 returned no document; errors=1 (swGenericError)",+                    )+                    .with_data(json!({+                        "interconnect": true,+                        "loadFile4": {"message": "LoadFile4 returned no document", "detail": Value::Null, "code": Value::Null},+                        "loadFile2": {"returned": false, "failure": Value::Null},+                        "errors": 1,+                        // WHAT THE COUNT DID — the one number that says whether a+                        // document was left behind, and the fact ownership turns+                        // on. It was in prose on one path and nowhere on the other.+                        // THE SAME NAMES THE SUCCESS REPLY USES for the same+                        // facts, so a caller reads one vocabulary either way.+                        "documentCountBefore": self.docs.len() as i32,+                        "documentCount": self.docs.len() as i32,+                        "documentsOpened": 0,+                        "documentAppeared": false,+                        "path": path,+                    })));+                }+                // THE LIVE FAILURE OF 2026-09-17, exactly as it came back: a type+                // mismatch on `importData`, which was being passed as VT_EMPTY+                // where SOLIDWORKS wanted a NULL DISPATCH.+                //+                // THE SIMULATOR CANNOT CHECK THE ENCODING. It has no VARIANTs —+                // it is Rust data structures — so no fault here can tell+                // `Var::empty()` from `Var::null_dispatch()`, and a test that+                // claimed to would be theatre. What this fault keeps honest is+                // the REPORTING path: the HRESULT and argument index reaching the+                // caller, which is what made the diagnosis possible at all. The+                // encoding itself is pinned where it exists — the Windows-only+                // unit test in variant.rs — and in tests/wiring.rs, which reads+                // the call site.+                if fault("mock-import-typemismatch") {+                    return Err(HostError::detailed(+                        format!(+                            "SOLIDWORKS could not import {path} (LoadFile4 opened nothing, \+                             errors=0; LoadFile2 refused). Importable formats depend on the \+                             installed translators."+                        ),+                        "SOLIDWORKS rejected 'LoadFile4': Type mismatch. (0x80020005) \+                         (argument index 1)",+                    )+                    .with_data(json!({+                        "loadFile4": {+                            "message": "SOLIDWORKS rejected 'LoadFile4'",+                            "detail": "Type mismatch. (0x80020005) (argument index 1)",+                            "code": Value::Null,+                            "hresult": "0x80020005",+                            "argumentIndex": 1,+                        },+                        "loadFile2": {"returned": false, "failure": Value::Null},+                        "errors": 0,+                        "path": path,+                    })));+                }+                if fault("mock-import-load4-throws") {+                    return Err(HostError::detailed(+                        format!(+                            "SOLIDWORKS could not import {path} (LoadFile4 opened nothing, \+                             errors=0; LoadFile2 refused). Importable formats depend on the \+                             installed translators."+                        ),+                        "SOLIDWORKS rejected 'LoadFile4': 0x80004005 [SldWorks] (argument index 0)",+                    )+                    .with_data(json!({+                        "loadFile4": {+                            "message": "SOLIDWORKS rejected 'LoadFile4'",+                            "detail": "0x80004005 [SldWorks]: translator not available \+                                       (argument index 0)",+                            "code": Value::Null,+                            "hresult": "0x80004005",+                        },+                        "loadFile2": {"returned": false, "failure": Value::Null},+                        "errors": 0,+                        "path": path,+                    })));+                }                 self.untitled += 1;                 // SOLIDWORKS names an imported document after the file's STEM                 // with a PART extension: importing `smoke.step` yields a@@ -2212,26 +2876,65 @@ impl MockBackend {                         "netCountOk": after - before == 1,                     }));                 }-                let temp_path =-                    format!("{MOCK_TEMP_ROOT}\\swx4242\\IC~~\\{}-{n}.SLDPRT", file_stem(&path));+                // F1 — THE INTERCONNECT ROUTE PRODUCES A PATHLESS DOCUMENT, as+                // the real one does. Giving it a `…\Temp\swx<pid>\IC~~\…` path+                // meant every drain and sweep test over this route exercised+                // FIND-BY-TEMP-PATH — the branch that cannot run on Drew's+                // machine — while the branch the route's own comment calls+                // load-bearing, registration at birth, was never the thing under+                // test. A simulator that makes the hard case easy is a simulator+                // that agrees with the bug.                 let mut doc = MockDoc::opened(key.clone(), "part", "Default".into());                 doc.title = title;                 doc.saved = false;-                doc.import_temp = true;-                doc.temp_path = Some(temp_path);+                if effective == "opendoc6" {+                    doc.import_temp = false;+                    doc.temp_path = None;+                } else {+                    doc.import_temp = true;+                    doc.temp_path = Some(format!(+                        "{MOCK_TEMP_ROOT}\\swx4242\\IC~~\\{}-{n}.SLDPRT",+                        file_stem(&path)+                    ));+                }                 doc.bodies.push(mock_body("Imported1", 50.0, 30.0, 20.0));                 self.docs.push(doc);                 self.active = Some(key.clone());                 let after = self.docs.len() as i32;                 let net = after - before;+                // THE SAME TWO ROUTES THE REAL BACKEND TAKES, and the same+                // reporting — including `linked`, which is the fact a caller most+                // needs and cannot see from the document itself: an interconnect+                // part is NOT self-contained.                 Ok(json!({                     "imported": self.doc_info(&key),-                    "via": "LoadFile4",+                    "via": match effective {+                        "opendoc6" => "OpenDoc6 (3D Interconnect)",+                        "loadfile2" => "LoadFile2",+                        _ => "LoadFile4",+                    },+                    "invocation": invocation,+                    "route": route,+                    "interconnect": if readable { json!(interconnect) } else { Value::Null },+                    "interconnectSettingReadable": readable,+                    "linked": effective == "opendoc6",+                    "sourcePath": if effective == "opendoc6" { json!(path) } else { Value::Null },                     "errors": 0,                     "documentsOpened": net,                     "netCountOk": net == 1,                     "documentCount": after,-                    "_hint": "Imported. This left ONE new open, unsaved document (like                               swx_new_document) — it stays open and holds memory until you save                               or close it. Importing repeatedly without closing is what leaks                               documents; swx_close_import_temps drains any left behind.",+                    "_hint": if effective == "opendoc6" {+                        "Imported by OPENING it as a part holding a LINKED 3D Interconnect feature \+                         — the user's SOLIDWORKS has 3D Interconnect ON, which this bridge reads \+                         and never changes. The part is NOT self-contained: moving or deleting the \+                         source file changes it. It is one new open, unsaved document; \+                         swx_close_import_temps drains any left behind."+                    } else {+                        "Imported. This left ONE new open, unsaved document (like \+                         swx_new_document) — it stays open and holds memory until you save or \+                         close it. Importing repeatedly without closing is what leaks documents; \+                         swx_close_import_temps drains any left behind."+                    },                 }))             } 
src/comhost/mod.rs+8−3
@@ -59,19 +59,23 @@ pub struct SidecarError {     /// copy of the sentence built below, in two other files, that would have     /// gone quietly false the day this wording changed.     pub timed_out: bool,+    /// THE STRUCTURED REMAINS OF THE INNER FAILURE, when the host had any — the+    /// HRESULT and EXCEPINFO of the COM call that actually broke. Prose is what a+    /// person reads; this is what a person DIAGNOSES from.+    pub data: Option<serde_json::Value>, }  impl SidecarError {     fn new(message: impl Into<String>, detail: Option<String>) -> Self {-        Self { message: message.into(), detail, code: None, timed_out: false }+        Self { message: message.into(), detail, code: None, timed_out: false, data: None }     }      fn timeout(message: impl Into<String>, detail: Option<String>) -> Self {-        Self { message: message.into(), detail, code: None, timed_out: true }+        Self { message: message.into(), detail, code: None, timed_out: true, data: None }     }      fn coded(message: impl Into<String>, detail: Option<String>, code: Option<String>) -> Self {-        Self { message: message.into(), detail, code, timed_out: false }+        Self { message: message.into(), detail, code, timed_out: false, data: None }     } } @@ -268,6 +272,7 @@ impl ComHost {                         let code =                             resp.get("errorCode").and_then(|v| v.as_str()).map(|s| s.to_string());                         let mut e = SidecarError::coded(message, detail, code);+                        e.data = resp.get("errorData").filter(|v| !v.is_null()).cloned();                         // The HOST may have given up on the call itself.                         e.timed_out = resp.get("timedOut").and_then(|v| v.as_bool()).unwrap_or(false);                         return Err(e);
src/comhost/variant.rs+77−1
@@ -84,6 +84,36 @@ impl Var {         }     } +    /// **"NOTHING", for an `[in]` OBJECT PARAMETER.**+    ///+    /// A `VT_DISPATCH` variant whose `pdispVal` is NULL — which is what a+    /// late-bound caller passes where VBA writes `Nothing`. It is NOT the same as+    /// `VT_EMPTY`, and this bridge believed it was from v0.2.0 until 2026-09-17.+    ///+    /// MEASURED THAT DAY, on the live install: every STEP import failed, and with+    /// the structured COM failure finally reaching the caller (0.7.8) it read+    ///+    /// ```text+    /// hostError.loadFile4.detail = "Type mismatch. (0x80020005) (argument index 1)"+    /// ```+    ///+    /// `rgvarg` is packed REVERSED (see `call_raw`), so `puArgErr = 1` is the+    /// THIRD parameter of `LoadFile4(path, "r", importData, byref errors)` —+    /// `importData` — which was being passed as `VT_EMPTY`. SOLIDWORKS wants an+    /// object there and will take a null one; it will not take "no value at all".+    ///+    /// The consequence worth recording: **`LoadFile4` had probably never worked+    /// live.** The imports of 09-05 to 09-11 went through the `LoadFile2`+    /// fallback, and the reason that route looked like the normal one was that+    /// nothing ever showed the caller why the first had failed.+    pub fn null_dispatch() -> Var {+        unsafe {+            build(VT_DISPATCH, |v| {+                (*v.Anonymous.Anonymous).Anonymous.pdispVal = std::mem::ManuallyDrop::new(None);+            })+        }+    }+     pub fn dispatch(d: &IDispatch) -> Var {         unsafe {             build(VT_DISPATCH, |v| {@@ -458,6 +488,10 @@ impl Disp {                     timed_out: false,                     // A rejected call did nothing at all, so it made nothing.                     created: None,+                    data: Some(serde_json::json!({+                        "hresult": format!("0x{:08X}", code as u32),+                        "call": name,+                    })),                 });             }         }@@ -472,7 +506,18 @@ impl Disp {             if arg_err != 0 || !args.is_empty() {                 detail.push_str(&format!(" (argument index {arg_err})"));             }-            HostError::detailed(format!("SOLIDWORKS rejected '{name}'"), detail)+            // AND THE SAME FACTS, STRUCTURED. The prose is for a person; the+            // HRESULT is what a person diagnoses a translator from, and it was+            // being flattened into a sentence that callers then truncated.+            HostError::detailed(format!("SOLIDWORKS rejected '{name}'"), detail).with_data(+                serde_json::json!({+                    "call": name,+                    "hresult": format!("0x{:08X}", e.code().0 as u32),+                    "source": if source.is_empty() { serde_json::Value::Null } else { serde_json::json!(source) },+                    "description": if description.is_empty() { serde_json::Value::Null } else { serde_json::json!(description) },+                    "argumentIndex": arg_err,+                }),+            )         })?;         Ok(Var(result))     }@@ -514,3 +559,34 @@ impl Disp {         self.call(name, args).ok().and_then(|v| v.as_bool())     } }++#[cfg(test)]+mod tests {+    use super::*;++    /// "NOTHING" IS NOT "OMITTED", and the difference is what broke every import.+    ///+    /// This runs only where `variant.rs` compiles — a Windows build — which is+    /// also the only place the encoding means anything. It is the one test that+    /// inspects the bytes: the simulator has no VARIANTs to look at, so it cannot+    /// tell these two apart no matter how it is written.+    #[test]+    fn nothing_is_a_null_dispatch_and_not_an_empty_variant() {+        let nothing = Var::null_dispatch();+        let omitted = Var::empty();+        unsafe {+            let n = &*nothing.0.Anonymous.Anonymous;+            let o = &*omitted.0.Anonymous.Anonymous;+            assert_eq!(+                n.vt, VT_DISPATCH,+                "an [in] object parameter set to Nothing must be VT_DISPATCH; SOLIDWORKS answered \+                 Type mismatch (0x80020005) to VT_EMPTY on LoadFile4's importData, live 2026-09-17"+            );+            assert!(+                n.Anonymous.pdispVal.is_none(),+                "Nothing means a NULL pdispVal, not a live interface pointer"+            );+            assert_eq!(o.vt, VT_EMPTY, "Var::empty must stay VT_EMPTY — it is a different thing");+        }+    }+}
src/disk.rsadded+331
@@ -0,0 +1,331 @@+//! HOW MUCH ROOM IS LEFT, and what this sweep is about to need.+//!+//! 0.7.2 verifies every recovery file by container marker and size, and pass 24+//! found the hole that leaves: the marker sits at offset 4, so ANY prefix of a+//! real document longer than eight bytes carries it. A write that reaches 20 KB+//! of a 500 KB assembly passes the marker AND the 16 KB floor. Marker and floor+//! together catch "empty" and "not a SOLIDWORKS file"; neither catches **the+//! right file, cut short**, and the format offers nothing cheap that would — no+//! constant tail, no length field, no head/tail relationship (measured).+//!+//! So the remaining defence is not in the file. It is in the decision to START:+//! this sweep writes every open document to disk, and it must refuse to begin+//! when the volume holds less room than the session it is about to write. A+//! truncation it cannot detect afterwards is one it declines to cause.++use std::path::Path;++/// Free bytes available on the volume holding `dir`, or `None` when that cannot+/// be measured.+///+/// `None` is an honest UNKNOWN and callers must treat it as one: this bridge does+/// not read "I could not measure the disk" as "there is room". The rule this+/// project keeps relearning is that an unanswerable question is not permission.+pub fn free_bytes(dir: &Path) -> Option<u64> {+    // A TEST KNOB, and the only way to hold a nearly-full volume in a suite.+    // A comma-separated list is consumed one entry per read, the last repeating,+    // so a scenario can be "room to start, and then the disk fills" — which is+    // the case that needs re-checking inside the loop rather than once at the+    // top.+    if let Ok(v) = std::env::var("SWX_TEST_FREE_BYTES") {+        let vals: Vec<u64> = v.split(',').filter_map(|x| x.trim().parse().ok()).collect();+        if !vals.is_empty() {+            let f = crate::scratch::root().join("test-free-bytes-reads");+            let n = std::fs::read_to_string(&f)+                .ok()+                .and_then(|t| t.trim().parse::<usize>().ok())+                .unwrap_or(0);+            let _ = std::fs::write(&f, (n + 1).to_string());+            return Some(vals[n.min(vals.len() - 1)]);+        }+    }+    platform_free_bytes(dir)+}++#[cfg(windows)]+fn platform_free_bytes(dir: &Path) -> Option<u64> {+    use windows::core::HSTRING;+    use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;+    // The directory need not exist yet — the sweep creates it — so ask about the+    // nearest ancestor that does.+    let mut probe = dir.to_path_buf();+    while !probe.exists() {+        match probe.parent() {+            Some(p) if p != probe => probe = p.to_path_buf(),+            _ => break,+        }+    }+    let mut free: u64 = 0;+    let h = HSTRING::from(probe.as_os_str());+    // FREE TO THE CALLER, not total free: on a volume with a quota those differ,+    // and the one that matters is what this process may actually write.+    unsafe { GetDiskFreeSpaceExW(&h, Some(&mut free), None, None) }.ok()?;+    Some(free)+}++#[cfg(not(windows))]+fn platform_free_bytes(dir: &Path) -> Option<u64> {+    // The bridge runs on Windows; this path exists so the suite measures a real+    // volume rather than a stub. `df -Pk` is POSIX-specified output: one header+    // line, then a row whose fourth field is available 1K-blocks.+    let mut probe = dir.to_path_buf();+    while !probe.exists() {+        match probe.parent() {+            Some(p) if p != probe => probe = p.to_path_buf(),+            _ => break,+        }+    }+    let out = std::process::Command::new("df").arg("-Pk").arg(&probe).output().ok()?;+    if !out.status.success() {+        return None;+    }+    let text = String::from_utf8_lossy(&out.stdout);+    let row = text.lines().nth(1)?;+    let avail_kb: u64 = row.split_whitespace().nth(3)?.parse().ok()?;+    Some(avail_kb.saturating_mul(1024))+}++/// What one pathless document is assumed to cost when nothing better is known+/// and its kind is not known either.+pub const UNKNOWN_DOCUMENT_ESTIMATE: u64 = 1024 * 1024;++/// WHAT KIND OF DOCUMENT, because the three kinds are not the same size.+///+/// Measured over the 166 real documents in this container:+///+/// ```text+///          n     min        median      max+/// .SLDPRT  99    29,130     134,885     37,200,505+/// .SLDASM  38    95,492     4,139,430   16,644,232+/// .SLDDRW  29    319,803    1,958,169   32,250,713+/// ```+///+/// An assembly is thirty times the median part. Costing one as if it were a part+/// is how a per-save gate passes with far less room than the write needs.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum DocKind {+    Part,+    Assembly,+    Drawing,+}++impl DocKind {+    /// From a FILE NAME'S EXTENSION, and nothing else. `None` when the extension+    /// is not one of the three.+    ///+    /// 0.7.4 substring-matched the whole hint, so a recovery folder called+    /// `…\Drawings\` reclassified every file under it as a drawing and a genuine+    /// median-sized part (134,885 bytes) was refused against the 256 KB drawing+    /// floor. A directory name is not evidence about a document.+    ///+    /// It returns `Option` because **the classifier must be able to say "I do not+    /// know"**. When it could not, every failure of the hint chain resolved to+    /// `Part` — the LOWEST floor on both sides — so an unreadable type produced+    /// the most permissive answer available. The two callers want opposite+    /// defaults for an unknown (the estimate wants generous, the verification+    /// wants strict), and neither can express that against a total function.+    pub fn from_extension(name: &str) -> Option<DocKind> {+        // The FILE's extension: a directory may contain dots, and everything+        // before the last separator is somebody's folder name, not this+        // document's kind.+        let file = name.rsplit(['/', '\\']).next().unwrap_or(name);+        let ext = file.rsplit_once('.').map(|(_, e)| e)?.to_ascii_lowercase();+        match ext.as_str() {+            "sldprt" => Some(DocKind::Part),+            "sldasm" => Some(DocKind::Assembly),+            "slddrw" => Some(DocKind::Drawing),+            _ => None,+        }+    }++    /// The general hint: an extension, and only an extension.+    pub fn from_hint(hint: &str) -> Option<DocKind> {+        Self::from_extension(hint)+    }++    /// A type NAME as the backends report it (`type` on the real one, `docType`+    /// on the simulator). Exact, not substring: this is a closed vocabulary.+    pub fn from_type_name(name: &str) -> Option<DocKind> {+        match name.trim().to_ascii_lowercase().as_str() {+            "part" => Some(DocKind::Part),+            "assembly" => Some(DocKind::Assembly),+            "drawing" => Some(DocKind::Drawing),+            _ => None,+        }+    }++    /// A TITLE: its extension if it has one, else the prefix SOLIDWORKS gives an+    /// unsaved document — `Assem1`, `Part3`, `Draw2`.+    ///+    /// The default title of a new assembly is `Assem1`, which 0.7.4 classified as+    /// a PART and costed at 256 KB — where having no hint at all would have cost+    /// the generic 1 MB, and the truth is an 8 MB assembly floor. A hint that+    /// makes the answer worse than no hint is worse than useless, and this is the+    /// common case rather than a corner.+    pub fn from_title(title: &str) -> Option<DocKind> {+        if let Some(k) = Self::from_extension(title) {+            return Some(k);+        }+        let t = title.trim().to_ascii_lowercase();+        if t.starts_with("assem") {+            Some(DocKind::Assembly)+        } else if t.starts_with("draw") {+            Some(DocKind::Drawing)+        } else if t.starts_with("part") {+            Some(DocKind::Part)+        } else {+            None+        }+    }++    /// The extension a recovery file of this kind is given.+    pub fn extension(self) -> &'static str {+        match self {+            DocKind::Part => "SLDPRT",+            DocKind::Assembly => "SLDASM",+            DocKind::Drawing => "SLDDRW",+        }+    }++    /// The name of this kind as the backends report it.+    pub fn type_name(self) -> &'static str {+        match self {+            DocKind::Part => "part",+            DocKind::Assembly => "assembly",+            DocKind::Drawing => "drawing",+        }+    }++    /// The least this kind of document is assumed to cost when its size cannot be+    /// measured. Each sits above the measured MEDIAN for its kind, because an+    /// estimate that is too low starts a write that cannot finish, and one that+    /// is too high refuses a sweep that would have worked.+    pub fn estimate_floor(self) -> u64 {+        match self {+            DocKind::Part => 256 * 1024,+            DocKind::Assembly => 8 * 1024 * 1024,+            DocKind::Drawing => 4 * 1024 * 1024,+        }+    }++    /// THE FLOOR TO APPLY WHEN THE KIND IS NOT KNOWN: the highest of the three.+    ///+    /// 0.7.4's comment argued the reverse — that a low default was right because+    /// "too high a floor refuses to close a real document". Both halves are true+    /// and the conclusion was backwards. A too-high floor REFUSES, loudly, naming+    /// the file and the number; the user loses nothing and runs it again. A+    /// too-low floor CLOSES a document against a file nobody checked. For a+    /// verification, refusing is the direction to fail in.+    pub fn unknown_kind_floor() -> u64 {+        DocKind::Drawing.min_recovery_bytes()+    }++    /// The least a written recovery file of this kind may be and still count.+    /// Each sits just BELOW the smallest real document of its kind measured here+    /// (29,130 / 95,492 / 319,803), so it can never reject a genuine document+    /// while still catching a write that stopped early.+    ///+    /// **THE PART FLOOR IS TIGHT, and the first live close said so.** On+    /// 2026-09-17 the sweep rescued four documents at 25,773-26,047 bytes each —+    /// an EMPTY part is about 25.8 KB, which clears the 24 KB floor by roughly+    /// 1.2 KB. That is the intended margin and not an accident: the floor exists+    /// to catch a write that stopped early, and an empty part is the smallest+    /// thing a real save can legitimately produce. But it is 5%, so anyone raising+    /// this number is raising it INTO real documents, and must measure a genuinely+    /// empty part on the target edition before touching it.+    pub fn min_recovery_bytes(self) -> u64 {+        match self {+            DocKind::Part => 24 * 1024,+            DocKind::Assembly => 80 * 1024,+            DocKind::Drawing => 256 * 1024,+        }+    }+}++/// The safety factor between what this sweep will write and the room it insists+/// on having first.+///+/// SaveAs3 does not write in place: a document is built and flushed, the old copy+/// of a re-saved file can still be on the volume while the new one lands, and the+/// estimate itself is only an estimate. Two times the estimate is the margin, and+/// it is applied to every check — the one before the sweep starts and the one+/// before each save.+pub const HEADROOM_FACTOR: u64 = 2;++/// WHAT THIS SWEEP IS ABOUT TO WRITE.+///+/// A document with a readable path costs about what it costs on disk now. One+/// with no path, or whose path this process cannot stat, costs an ESTIMATE that+/// never falls below the floor for its KIND. Unknowns are rounded UP, because the+/// direction of a wrong estimate decides whether this refuses a sweep it could+/// have done (a nuisance) or starts one it cannot finish (a truncated file it+/// cannot detect).+pub struct WriteEstimate {+    /// Total bytes this sweep is expected to write.+    pub total: u64,+    /// The largest single document, which is the room the NEXT save needs.+    pub largest: u64,+    /// How many documents had a size this process could actually read.+    pub measured: usize,+    /// How many were estimated instead.+    pub estimated: usize,+}++/// Estimate from paths alone, inferring each document's kind from its extension.+/// A pathless document has no extension to read, so it costs the generic unknown.+pub fn estimate_writes(paths: &[String]) -> WriteEstimate {+    let docs: Vec<(String, Option<DocKind>)> = paths+        .iter()+        .map(|p| {+            let kind = if p.trim().is_empty() { None } else { DocKind::from_hint(p) };+            (p.clone(), kind)+        })+        .collect();+    estimate_writes_typed(&docs)+}++/// Estimate when the caller KNOWS each document's kind — which the sweep does,+/// because SOLIDWORKS told it.+///+/// The rule that matters is MONOTONICITY: **a document this process could measure+/// must never lower the assumed cost of one it could not.** 0.7.3 costed an+/// unknown at twice the largest measured document, so a single 29 KB part beside+/// a pathless assembly dropped that assembly's assumed cost from 1 MB to 58 KB —+/// learning something small about the session made the guess about the rest of it+/// smaller, and `largest` also gates the re-check before every save. The fix is+/// one `max` against a floor that depends only on the KIND.+pub fn estimate_writes_typed(docs: &[(String, Option<DocKind>)]) -> WriteEstimate {+    let sizes: Vec<Option<u64>> = docs+        .iter()+        .map(|(p, _)| {+            if p.trim().is_empty() {+                None+            } else {+                std::fs::metadata(p).ok().map(|m| m.len())+            }+        })+        .collect();+    let biggest_known = sizes.iter().flatten().copied().max().unwrap_or(0);+    let neighbour_cost = biggest_known.saturating_mul(2);+    let mut total = 0u64;+    let mut largest = 0u64;+    let mut measured = 0usize;+    let mut estimated = 0usize;+    for (s, (_, kind)) in sizes.iter().zip(docs.iter()) {+        let cost = match s {+            Some(n) => {+                measured += 1;+                *n+            }+            None => {+                estimated += 1;+                // NEVER BELOW THE KIND'S FLOOR, whatever the neighbours weigh.+                let floor = kind.map(|k| k.estimate_floor()).unwrap_or(UNKNOWN_DOCUMENT_ESTIMATE);+                neighbour_cost.max(floor)+            }+        };+        total = total.saturating_add(cost);+        largest = largest.max(cost);+    }+    WriteEstimate { total, largest, measured, estimated }+}
src/launchos.rs+205
@@ -262,6 +262,82 @@ impl WindowInfo {     } } +/// The readable contents of a dialog: what it says, and what it offers.+#[derive(Debug, Clone, Default, PartialEq)]+pub struct DialogContents {+    /// Every static text line, in z-order.+    pub text: Vec<String>,+    /// The name on every PUSH BUTTON. Checkboxes are not buttons here — see+    /// `checkboxes` — because "Don't show this again" is not a choice about what+    /// happens to the user's work.+    pub buttons: Vec<String>,+    /// Checkbox labels, kept separately and never counted as choices.+    pub checkboxes: Vec<String>,+    /// TRUE if any control's text hit the read buffer and may be cut short. The+    /// reads are 1024 wide; a longer message is reported as truncated rather than+    /// silently shortened, because the part that got cut is exactly where a+    /// countdown or a file name tends to live.+    pub truncated: bool,+}++/// CLASSIFY ONE CONTROL from what Win32 says about it.+///+/// A Win32 CHECKBOX HAS CLASS "Button" — `BS_CHECKBOX` (0x02) and+/// `BS_AUTOCHECKBOX` (0x03) are STYLE bits, not classes — so a control tree read+/// by class alone files "Don't show this again" as a button. That matters because+/// `acknowledge_only` counts buttons: a licence dialog with OK plus a "do not+/// show again" box would have read as two choices and stopped being+/// acknowledge-only, and a dialog with only a checkbox would have read as one.+///+/// Style is the low 4 bits of the button type field.+pub fn classify_control(class: &str, style: u32, text: &str) -> Option<&'static str> {+    if text.trim().is_empty() {+        return None;+    }+    let c = class.to_ascii_lowercase();+    if c.contains("button") {+        // BS_CHECKBOX = 0x2, BS_AUTOCHECKBOX = 0x3, BS_3STATE = 0x5,+        // BS_AUTO3STATE = 0x6, BS_RADIOBUTTON = 0x4, BS_AUTORADIOBUTTON = 0x9.+        return Some(match style & 0xF {+            0x2 | 0x3 | 0x5 | 0x6 => "checkbox",+            0x4 | 0x9 => "radio",+            _ => "button",+        });+    }+    if c.contains("static") || c.contains("edit") {+        return Some("text");+    }+    None+}++impl DialogContents {+    pub fn to_json(&self) -> Value {+        json!({+            "text": self.text,+            "buttons": self.buttons,+            "checkboxes": self.checkboxes,+            "truncated": self.truncated,+        })+    }+    /// A dialog whose only choice is to acknowledge it: EXACTLY ONE button, and+    /// that button is an acknowledgement. Any second button is a choice, and a+    /// choice is never acknowledge-only — "OK" beside "Cancel" decides something.+    ///+    /// This says nothing about whether this bridge may PRESS it. It may not:+    /// `never_answer` stays true, and whether an OK-only informational dialog is+    /// ever pressable is a policy question for the user, not a deduction from a+    /// control tree.+    pub fn acknowledge_only(&self) -> bool {+        let named: Vec<String> = self+            .buttons+            .iter()+            .map(|b| b.trim().trim_start_matches('&').replace('&', "").to_ascii_lowercase())+            .filter(|b| !b.is_empty())+            .collect();+        named.len() == 1 && matches!(named[0].as_str(), "ok" | "close")+    }+}+ /// What SOLIDWORKS is costing the machine right now. #[derive(Clone, Debug, Default, PartialEq)] pub struct ProcMetrics {@@ -336,6 +412,26 @@ pub trait LaunchOs: Send + Sync {         Err("this host cannot enumerate windows".into())     } +    /// WHAT A DIALOG SAYS, AND WHICH BUTTONS IT HAS. Read-only, and `Err` is+    /// UNKNOWN — never "it says nothing".+    ///+    /// Live, 2026-09-17: the safe-close sweep stopped correctly on a dialog and+    /// reported `{"kind":"license","title":"License Manager"}` — the title and+    /// nothing else. Read by hand afterwards, the window said:+    ///+    /// > Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional:+    /// > Unable to connect to server. The session will end after the next 5+    /// > unsuccessful attempts.+    ///+    /// with a single button, OK. A title alone cannot distinguish "your licence+    /// expired" from "the licence server is unreachable and the session dies in+    /// five tries" — and the second is a countdown a person needs to see.+    ///+    /// THIS NEVER PRESSES ANYTHING. It reads.+    fn dialog_contents(&self, _hwnd: i64) -> Result<DialogContents, String> {+        Err("this host cannot read dialog contents".into())+    }+     /// This process's own pid — recorded on a launch attempt so a LATER bridge     /// can tell "somebody is supervising this" from "this was left behind".     fn self_pid(&self) -> u32 {@@ -969,6 +1065,50 @@ impl LaunchOs for FakeOs {     /// The simulated desktop: `windows.json`, scoped to the pid when a row names     /// one. `window-census-fails` makes the census UNREADABLE, which callers must     /// treat as unknown rather than as "nothing on screen".+    fn dialog_contents(&self, hwnd: i64) -> Result<DialogContents, String> {+        if self.dir.join("dialog-contents-unreadable").exists() {+            return Err("simulated: the dialog's controls could not be read".into());+        }+        // The scenario's own dialog carries its text and buttons, so a test can+        // hold the LIVE licence dialog exactly as it was read on 2026-09-17.+        let d = self.read_json("dialog-open.json");+        let matches = d.get("hwnd").and_then(|v| v.as_i64()).map(|h| h == hwnd).unwrap_or(false);+        if !matches {+            return Err("no such dialog in this scenario".into());+        }+        let list = |k: &str| -> Vec<String> {+            d.get(k)+                .and_then(|v| v.as_array())+                .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())+                .unwrap_or_default()+        };+        // THE SIMULATOR CAN HAND BACK RAW CONTROLS TOO. Pre-classified lists are+        // convenient and prove nothing about the classifier, so a scenario may+        // instead give `controls: [[class, style, text], …]` and go through the+        // same `classify_control` the real host uses.+        if let Some(rows) = d.get("controls").and_then(|v| v.as_array()) {+            let mut out = DialogContents::default();+            for r in rows {+                let class = r.get(0).and_then(|v| v.as_str()).unwrap_or("");+                let style = r.get(1).and_then(|v| v.as_u64()).unwrap_or(0) as u32;+                let text = r.get(2).and_then(|v| v.as_str()).unwrap_or("").to_string();+                match classify_control(class, style, &text) {+                    Some("button") => out.buttons.push(text),+                    Some("checkbox") | Some("radio") => out.checkboxes.push(text),+                    Some("text") => out.text.push(text),+                    _ => {}+                }+            }+            return Ok(out);+        }+        Ok(DialogContents {+            text: list("text"),+            buttons: list("buttons"),+            checkboxes: list("checkboxes"),+            truncated: d.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false),+        })+    }+     fn windows_of_pid(&self, pid: u32) -> Result<Vec<WindowInfo>, String> {         if self.dir.join("window-census-fails").exists() {             return Err("simulated: the window census could not be taken".into());@@ -1379,6 +1519,71 @@ mod win {          /// Every top-level window owned by `pid`, with its class — the quit         /// ladder's P3 instrument, and readable with no ab and no COM.+        /// READ-ONLY, and it presses nothing. `EnumChildWindows` walks the+        /// dialog's controls; `GetClassNameW` says what each one is and+        /// `GetWindowTextW` what it says. Static text becomes `text`, buttons+        /// become `buttons`.+        ///+        /// Measured 2026-09-17 on the live licence dialog: one static line+        /// ("Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional:+        /// Unable to connect to server. The session will end after the next 5+        /// unsuccessful attempts.") and one button, OK. The sweep and the quit+        /// are both blocked while it is up, and it appeared when the 3DEXPERIENCE+        /// licence heartbeat failed — it coincided with the desktop dropping off+        /// the relay.+        fn dialog_contents(&self, hwnd: i64) -> Result<DialogContents, String> {+            use windows::core::BOOL;+            use windows::Win32::UI::WindowsAndMessaging::{+                EnumChildWindows, GetClassNameW, GetWindowTextW,+            };+            static CHILDREN: std::sync::Mutex<Vec<i64>> = std::sync::Mutex::new(Vec::new());+            unsafe extern "system" fn child_proc(h: HWND, _: LPARAM) -> BOOL {+                if let Ok(mut g) = CHILDREN.lock() {+                    g.push(h.0 as i64);+                }+                BOOL(1)+            }+            let _serialised = ENUM_LOCK.lock().unwrap_or_else(|e| e.into_inner());+            CHILDREN.lock().unwrap_or_else(|e| e.into_inner()).clear();+            let parent = HWND(hwnd as *mut std::ffi::c_void);+            unsafe {+                let _ = EnumChildWindows(Some(parent), Some(child_proc), LPARAM(0));+            }+            let kids = CHILDREN.lock().unwrap_or_else(|e| e.into_inner()).clone();+            if kids.is_empty() {+                return Err("the dialog reported no child controls".into());+            }+            let mut out = DialogContents::default();+            for k in kids {+                let h = HWND(k as *mut std::ffi::c_void);+                let mut class = [0u16; 256];+                let mut text = [0u16; 1024];+                let (cl, tl) =+                    unsafe { (GetClassNameW(h, &mut class), GetWindowTextW(h, &mut text)) };+                let class = String::from_utf16_lossy(&class[..cl.max(0) as usize]);+                let body = String::from_utf16_lossy(&text[..tl.max(0) as usize]).trim().to_string();+                // A FULL BUFFER MAY BE A CUT MESSAGE. The reads are 1024 wide and+                // the part that gets cut is where a countdown or a file name lives.+                if tl as usize >= text.len().saturating_sub(1) {+                    out.truncated = true;+                }+                // STYLE, not class: a Win32 checkbox IS class "Button".+                let style = unsafe {+                    windows::Win32::UI::WindowsAndMessaging::GetWindowLongPtrW(+                        h,+                        windows::Win32::UI::WindowsAndMessaging::GWL_STYLE,+                    )+                } as u32;+                match classify_control(&class, style, &body) {+                    Some("button") => out.buttons.push(body),+                    Some("checkbox") | Some("radio") => out.checkboxes.push(body),+                    Some("text") => out.text.push(body),+                    _ => {}+                }+            }+            Ok(out)+        }+         fn windows_of_pid(&self, pid: u32) -> Result<Vec<WindowInfo>, String> {             use windows::Win32::UI::WindowsAndMessaging::{GetClassNameW, GetWindowTextW};             let _serialised = ENUM_LOCK.lock().unwrap_or_else(|e| e.into_inner());
src/lib.rs+1
@@ -38,6 +38,7 @@ pub mod launchos; pub mod ledger; pub mod owned; pub mod quit;+pub mod disk; pub mod scratch; pub mod script; pub mod server;
src/quit.rs+32−5
@@ -212,11 +212,38 @@ pub struct BlockingDialog {     pub hwnd: i64,     pub title: String,     pub kind: &'static str,+    /// WHAT IT SAYS AND WHAT IT OFFERS. `None` = not read, or unreadable —+    /// UNKNOWN, never "it said nothing".+    pub contents: Option<crate::launchos::DialogContents>, }  impl BlockingDialog {     pub fn to_json(&self) -> Value {-        json!({"hwnd": self.hwnd, "title": self.title, "kind": self.kind})+        let c = self.contents.as_ref();+        json!({+            "hwnd": self.hwnd,+            "title": self.title,+            "kind": self.kind,+            // A TITLE IS NOT THE MESSAGE. Live 2026-09-17 the sweep stopped on+            // `{"kind":"license","title":"License Manager"}` — correct, and+            // unactionable: the window actually said the licence SERVER was+            // unreachable and the session would end after five more failed+            // attempts. That is a countdown somebody needs to see.+            "text": c.map(|c| json!(c.text)).unwrap_or(Value::Null),+            "buttons": c.map(|c| json!(c.buttons)).unwrap_or(Value::Null),+            "contentsReadable": c.is_some(),+            // Whether the only thing on offer is acknowledgement. This bridge+            // still NEVER presses it — see `never_answer` — and whether it ever+            // may is a question for the user, not an inference from a control+            // tree.+            "acknowledgeOnly": c.map(|c| json!(c.acknowledge_only())).unwrap_or(Value::Null),+            "neverAnswer": true,+        })+    }++    /// READ WHAT IT SAYS. Read-only; a failure leaves `contents` as `None`.+    pub fn read_contents(&mut self, os: &dyn crate::launchos::LaunchOs) {+        self.contents = os.dialog_contents(self.hwnd).ok();     }     /// The error code this dialog produces. A save prompt is called out     /// separately because at a VERIFIED zero documents it should be impossible —@@ -238,7 +265,7 @@ impl BlockingDialog { pub fn first_blocking_dialog(windows: &[WindowInfo]) -> Option<BlockingDialog> {     windows.iter().filter(|w| w.is_dialog() && w.visible).map(|w| {         let (kind, _blocking, _never) = crate::abwin::classify(&w.title);-        BlockingDialog { hwnd: w.hwnd, title: w.title.clone(), kind }+        BlockingDialog { hwnd: w.hwnd, title: w.title.clone(), kind, contents: None }     }).next() } @@ -698,7 +725,7 @@ Some(0), Some(0), false, Some(3), None, true, Some(false), false, Some(true), So             Some("unsaved_documents")         );         // ...and force waives NONE of the others.-        let dialog = BlockingDialog { hwnd: 9, title: "Save changes?".into(), kind: "save_changes" };+        let dialog = BlockingDialog { hwnd: 9, title: "Save changes?".into(), kind: "save_changes", contents: None };         let cases: Vec<(&str, Result<(), Refusal>)> = vec![             ("open docs", check_preconditions( Some(2), Some(2), false, Some(0), None, true, Some(false), false, Some(true), Some(&id()), false, true)),@@ -751,9 +778,9 @@ Some(0), Some(0), false, Some(0), None, true, Some(false), false, Some(true), No      #[test]     fn a_save_prompt_gets_its_own_code_because_it_should_be_impossible() {-        let save = BlockingDialog { hwnd: 9, title: "Save changes to Part1?".into(), kind: "save_changes" };+        let save = BlockingDialog { hwnd: 9, title: "Save changes to Part1?".into(), kind: "save_changes", contents: None };         assert_eq!(save.error_code(), "stop_blocked_by_save_prompt");-        let other = BlockingDialog { hwnd: 9, title: "3DEXPERIENCE sign in".into(), kind: "license" };+        let other = BlockingDialog { hwnd: 9, title: "3DEXPERIENCE sign in".into(), kind: "license", contents: None };         assert_eq!(other.error_code(), "stop_blocked_by_dialog");         // ...and each stops the ladder from the precondition sweep.         for d in [&save, &other] {
src/scratch.rs+437−14
@@ -1,22 +1,51 @@-//! The bridge's scratch space — and the promise to prune it.+//! The bridge's DATA — and why it no longer lives where the bridge is installed. //!-//! Everything this bridge writes on its own initiative (default export paths,-//! generated BOM CSVs) lands under one root:+//! Everything this bridge writes on its own initiative lands under one root:+//! the recovery folders, the action ledger, the ownership registry, launch+//! state, verb timings, and the scratch files it gives its own documents. //! //! ```text-//! %LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\exports\+//! %LOCALAPPDATA%\Adom Bridge\solidworks-data\ //! ``` //!+//! **SEV-1, MEASURED 2026-09-30.** Until 0.9.0 that root was+//! `%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\` — the directory ab+//! INSTALLS the bridge into. ab's updater re-extracts that whole directory on+//! every release, and Drew's was found CREATED 2026-09-18 09:42 by the+//! auto-install of 0.8.9: every recovery folder from 09-17, the ledger, the+//! ownership registry and the scratch documents were gone. Only test documents+//! were in them, so nothing of his was lost — but the recovery folder is by+//! design the ONLY copy of work the user had not saved, and it was living in a+//! directory that a routine update deletes.+//!+//! So the data root is now a sibling of the install cache rather than a child of+//! it, anything found in the legacy location is MIGRATED on start (copied,+//! verified byte for byte, originals left where they were), `swx_recover_list`+//! still reads the legacy location, and `refuse_inside_install` stops a recovery+//! folder ever being placed under the directory the bridge runs from.+//!+//! WHAT THE MIGRATION CANNOT DO: the update that installs 0.9.0 re-extracts the+//! legacy directory BEFORE 0.9.0 first runs, so on a machine updated by ab from+//! 0.8.9 whatever 0.8.9 wrote there is most likely gone by the time the+//! migration looks. The migration covers every case where the directory+//! survives (an install that overlays rather than re-extracts, a copy run from+//! elsewhere); the move of the root is what protects every update after this one.+//! //! `SWX_SCRATCH_DIR` overrides the root (the test harness points it at a temp-//! dir); with neither, we fall back to the OS temp dir. `swx_uninstall` deletes-//! exactly this root and nothing else.+//! dir); `SWX_LEGACY_DIR` and `SWX_INSTALL_DIR` override the two install+//! locations for the same reason. With neither `SWX_SCRATCH_DIR` nor+//! `LOCALAPPDATA` we fall back to the OS temp dir. `swx_uninstall` removes the+//! bridge's own bookkeeping and `exports/` under this root, and NEVER the+//! recovery folders or the ledger: a recovery file is the user's only copy of+//! work they had not saved, and the ledger is the record of what was done.  use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Duration, SystemTime};  /// Everything the bridge creates lives under here — and nothing else does,-/// which is what makes `swx_uninstall` safe to run blind.+/// which is what makes `swx_uninstall` safe to run blind. NEVER inside the+/// install directory: see the module comment for what that cost. pub fn root() -> PathBuf {     if let Ok(dir) = std::env::var("SWX_SCRATCH_DIR") {         if !dir.is_empty() {@@ -25,10 +54,350 @@ pub fn root() -> PathBuf {     }     if let Ok(local) = std::env::var("LOCALAPPDATA") {         if !local.is_empty() {-            return Path::new(&local).join("Adom Bridge").join("bridges-cache").join("solidworks");+            // A SIBLING of `bridges-cache`, not a child of it.+            return Path::new(&local).join("Adom Bridge").join("solidworks-data");+        }+    }+    std::env::temp_dir().join("adom-bridge-swx-data")+}++/// WHERE THIS BRIDGE'S DATA USED TO LIVE — ab's install cache. Read for+/// migration and by `swx_recover_list`; never written.+pub fn legacy_root() -> Option<PathBuf> {+    if let Ok(dir) = std::env::var("SWX_LEGACY_DIR") {+        if !dir.is_empty() {+            return Some(PathBuf::from(dir));+        }+    }+    if let Ok(local) = std::env::var("LOCALAPPDATA") {+        if !local.is_empty() {+            return Some(Path::new(&local).join("Adom Bridge").join("bridges-cache").join("solidworks"));+        }+    }+    None+}++/// The directory the running bridge was INSTALLED into — whatever ab extracted.+/// Anything placed under it is gone at the next update.+pub fn install_dir() -> Option<PathBuf> {+    if let Ok(dir) = std::env::var("SWX_INSTALL_DIR") {+        if !dir.is_empty() {+            return Some(PathBuf::from(dir));+        }+    }+    std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()))+}++/// A path as the filesystem would resolve it, so that a comparison cannot be+/// talked out of its answer by spelling: `.` and `..` are folded first, then the+/// nearest ancestor that EXISTS is canonicalized (which resolves symlinks,+/// junctions and 8.3 short names) and the not-yet-created remainder is put back.+/// A recovery folder usually does not exist yet when it is checked, which is why+/// canonicalizing the whole path is not enough.+fn resolved(p: &Path) -> PathBuf {+    use std::path::Component;+    let mut lex = PathBuf::new();+    for c in p.components() {+        match c {+            Component::CurDir => {}+            Component::ParentDir => {+                lex.pop();+            }+            other => lex.push(other.as_os_str()),+        }+    }+    let mut base = lex.clone();+    let mut tail: Vec<std::ffi::OsString> = Vec::new();+    loop {+        if let Ok(c) = std::fs::canonicalize(&base) {+            let mut out = c;+            for t in tail.iter().rev() {+                out.push(t);+            }+            return out;+        }+        match (base.file_name().map(|f| f.to_os_string()), base.parent()) {+            (Some(name), Some(parent)) => {+                tail.push(name);+                base = parent.to_path_buf();+            }+            _ => return lex,+        }+    }+}++/// Is `inner` the same as, or inside, `outer`? Both are RESOLVED first (see+/// `resolved`), then compared case-insensitively with both separators, which is+/// how Windows matches a path. Erring towards "inside" is the safe direction for+/// every caller: it can only refuse a location, never approve one.+pub fn is_within(inner: &Path, outer: &Path) -> bool {+    let norm = |p: &Path| {+        let s = resolved(p).to_string_lossy().replace('/', "\\").to_ascii_lowercase();+        // A canonical Windows path comes back in its verbatim form.+        let s = s.strip_prefix("\\\\?\\unc\\").map(|r| format!("\\\\{r}")).unwrap_or(s);+        let s = s.strip_prefix("\\\\?\\").map(|r| r.to_string()).unwrap_or(s);+        s.trim_end_matches('\\').to_string()+    };+    let (i, o) = (norm(inner), norm(outer));+    !o.is_empty() && (i == o || i.starts_with(&format!("{o}\\")))+}++/// REFUSE A PATH THAT AN UPDATE WOULD DELETE.+///+/// A recovery folder is the user's only copy of work they had not saved. It must+/// never be placed under the directory the bridge is installed in, nor under+/// ab's install cache, because both are re-extracted on update. The root is+/// already outside them by construction; this is the check that keeps it that+/// way if an override, a future fallback or an ab change ever points it back+/// inside.+pub fn refuse_inside_install(path: &Path) -> Result<(), String> {+    for (what, dir) in [("the bridge's install directory", install_dir()),+                        ("ab's install cache", legacy_root())]+    {+        if let Some(d) = dir {+            // INSIDE OR EQUAL: the install location itself is re-extracted just+            // the same as anything under it.+            if is_within(path, &d) {+                return Err(format!(+                    "refusing to write {} under {what} ({}): that directory is re-extracted when \+                     the bridge is updated, which DELETES everything in it — measured 2026-09-30: \+                     the 2026-09-18 auto-install of 0.8.9 wiped every recovery folder written the \+                     day before. A recovery file is the user's only copy of unsaved work, so it \+                     goes in a data root outside it (by default {}).",+                    path.display(),+                    d.display(),+                    root().display()+                ));+            }+        }+    }+    Ok(())+}++/// COPY WHAT THE LEGACY LOCATION HOLDS INTO THE DATA ROOT.+///+/// Copied, VERIFIED byte for byte, and the originals LEFT where they were: this+/// runs against the directory an update is about to overwrite, but it is not+/// this code's place to delete anything from it. A file that fails verification+/// is reported and its copy removed, so a half-copied rescue never sits in the+/// new root looking like a whole one. A file that already exists in the new root+/// is never overwritten.+///+/// EACH FILE ONCE. `migrated-from-legacy.json` in the root is cumulative: every+/// file a run copied, or found already present, is SETTLED and never copied+/// again — so a copy the user deletes stays deleted. What is not settled is+/// tried on every start: a file whose copy failed verification last time, and a+/// file that appeared in the legacy location since (an older bridge run again,+/// after a rollback). Returns the report of THIS run, or `None` when there was+/// nothing new to do.+pub fn migrate_legacy() -> Option<serde_json::Value> {+    use serde_json::{json, Value};+    let legacy = legacy_root()?;+    let dest = root();+    if !legacy.is_dir() || is_within(&dest, &legacy) || is_within(&legacy, &dest) {+        return None;+    }+    let marker = dest.join("migrated-from-legacy.json");+    let previous: Value = std::fs::read_to_string(&marker)+        .ok()+        .and_then(|t| serde_json::from_str::<Value>(&t).ok())+        .unwrap_or(Value::Null);+    let list = |key: &str| -> Vec<Value> {+        previous.get(key).and_then(|v| v.as_array()).cloned().unwrap_or_default()+    };+    let (copied_before, skipped_before) = (list("copied"), list("skipped"));+    let settled: std::collections::HashSet<String> = copied_before+        .iter()+        .chain(skipped_before.iter())+        .filter_map(|e| e.get("file").and_then(|f| f.as_str()).map(|f| f.to_string()))+        .collect();+    // THE PERSISTENT STATE, named. Not the whole directory: the install cache+    // also holds the bridge's own executable and resources, which are not data.+    // Each registry's previous generation (`.bak`, kept by `write_atomic`) goes+    // with it: it is what `owned::restore` falls back to when the live file+    // cannot be read.+    const ITEMS: &[&str] = &[+        "recovery",+        "ledger.jsonl",+        "ledger.bak",+        "owned-docs.json",+        "owned-docs.bak",+        "com-host-created.json",+        "com-host-created.bak",+        "launch-state.json",+        "launch-state.bak",+        "timings.json",+        "timings.bak",+        "settings.json",+        "settings.bak",+        "exports",+    ];+    // Bounded: a link that points back up the tree must not recurse forever.+    fn walk(src: &Path, rel: &Path, out: &mut Vec<PathBuf>, depth: u32) {+        let here = src.join(rel);+        if here.is_file() {+            out.push(rel.to_path_buf());+        } else if depth < 16 {+            if let Ok(rd) = std::fs::read_dir(&here) {+                for e in rd.flatten() {+                    walk(src, &rel.join(e.file_name()), out, depth + 1);+                }+            }+        }+    }+    let mut files: Vec<PathBuf> = Vec::new();+    for item in ITEMS {+        if legacy.join(item).exists() {+            walk(&legacy, Path::new(item), &mut files, 0);+        }+    }+    files.retain(|rel| !settled.contains(rel.to_string_lossy().as_ref()));+    // IN A FIXED ORDER, so what an interrupted start leaves behind does not+    // depend on the order a directory listing happened to come back in.+    files.sort();+    if files.is_empty() {+        return None;+    }+    let _ = std::fs::create_dir_all(&dest);+    let mut copied: Vec<Value> = Vec::new();+    let mut failed: Vec<Value> = Vec::new();+    let mut skipped: Vec<Value> = Vec::new();+    for rel in files {+        let from = legacy.join(&rel);+        let to = dest.join(&rel);+        let name = rel.to_string_lossy().to_string();+        if to.exists() {+            skipped.push(json!({"file": name, "why": "already present in the data root; never overwritten"}));+            continue;+        }+        if let Some(parent) = to.parent() {+            let _ = std::fs::create_dir_all(parent);+        }+        // UNDER A TEMPORARY NAME, THEN RENAMED. A start that dies mid-copy — a+        // crash, a power cut, ab killing a bridge slow to answer /status — must+        // not leave a truncated file under the REAL name: the never-overwrite+        // rule above would then protect the truncated copy forever, and the next+        // update would delete the only whole one. A leftover temporary from an+        // interrupted start is simply replaced.+        let tmp = to.with_file_name(format!(+            "{}.migrating",+            to.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()+        ));+        let _ = std::fs::remove_file(&tmp);+        let copied_ok = copy_for_migration(&from, &tmp, &dest);+        // TEST-ONLY FAULT, mock mode only: a copy that lands DIFFERENT from its+        // original — what a failing disk, or a sync client rewriting the file+        // mid-copy, looks like. Nothing in production creates this file, and+        // outside mock mode it is not even looked for.+        if copied_ok && crate::is_mock() && dest.join("mock-migrate-corrupts-copy").exists() {+            use std::io::Write;+            if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(&tmp) {+                let _ = f.write_all(b"!");+            }+        }+        let verified = copied_ok && same_bytes(&from, &tmp) && std::fs::rename(&tmp, &to).is_ok();+        if verified {+            copied.push(json!({+                "file": name,+                "bytes": std::fs::metadata(&to).map(|m| m.len()).unwrap_or(0),+            }));+        } else {+            // A COPY THAT DOES NOT MATCH IS NOT A COPY. Remove it rather than+            // leave a truncated rescue that reads as a whole one; the original+            // is still in the legacy location, untouched, and the next start+            // tries it again. A folder left empty by that goes too, so the+            // listing does not show an empty rescue beside the real one.+            let _ = std::fs::remove_file(&tmp);+            let mut dir = to.parent().map(|p| p.to_path_buf());+            while let Some(d) = dir {+                if d == dest || !d.starts_with(&dest) || std::fs::remove_dir(&d).is_err() {+                    break;+                }+                dir = d.parent().map(|p| p.to_path_buf());+            }+            failed.push(json!({"file": name, "why": "copy did not verify byte for byte"}));+        }+    }+    let why = "the legacy location is ab's install cache, which is re-extracted on every update \+               (measured 2026-09-30: the 2026-09-18 auto-install of 0.8.9 deleted every recovery \+               folder from the day before). Data now lives in this directory instead.";+    let report = json!({+        "from": legacy.to_string_lossy(),+        "to": dest.to_string_lossy(),+        "copied": copied,+        "failed": failed,+        "skipped": skipped,+        "originalsLeftInPlace": true,+        "why": why,+    });+    // THE CUMULATIVE RECORD: everything ever settled, and what is still failing.+    let record = json!({+        "from": legacy.to_string_lossy(),+        "to": dest.to_string_lossy(),+        "copied": copied_before.into_iter().chain(copied.iter().cloned()).collect::<Vec<_>>(),+        "skipped": skipped_before.into_iter().chain(skipped.iter().cloned()).collect::<Vec<_>>(),+        "failed": failed,+        "originalsLeftInPlace": true,+        "lastRunAtMs": crate::launchos::epoch_ms() as u64,+        "why": why,+    });+    if let Ok(text) = serde_json::to_string_pretty(&record) {+        let _ = crate::launch::write_atomic(&marker, &text);+    }+    Some(report)+}++/// The copy itself. Separate so the one test-only fault that models an+/// INTERRUPTED start can sit exactly where an interruption would: mid-copy, into+/// whatever path the migration copies to. In mock mode only, with+/// `mock-migrate-dies-mid-copy` in the data root (its content: how many copies to+/// let through first, default none), it writes the first half of the file and+/// the process exits — nothing in production creates that file, and outside mock+/// mode it is not even looked for.+fn copy_for_migration(from: &Path, to: &Path, dest: &Path) -> bool {+    if crate::is_mock() {+        if let Ok(spec) = std::fs::read_to_string(dest.join("mock-migrate-dies-mid-copy")) {+            use std::sync::atomic::{AtomicUsize, Ordering};+            static DONE: AtomicUsize = AtomicUsize::new(0);+            let allowed: usize = spec.trim().parse().unwrap_or(0);+            if DONE.fetch_add(1, Ordering::SeqCst) >= allowed {+                if let Ok(bytes) = std::fs::read(from) {+                    let _ = std::fs::write(to, &bytes[..bytes.len() / 2]);+                }+                eprintln!("[swx] mock-migrate-dies-mid-copy: exiting mid-copy of {}", from.display());+                std::process::exit(3);+            }+        }+    }+    std::fs::copy(from, to).is_ok()+}++/// BYTE FOR BYTE, streamed: a recovery file can be an assembly of hundreds of+/// megabytes, and reading two of them whole to compare them would be the way a+/// migration on a machine short of memory fails. Anything unreadable is "not the+/// same", which is the direction that keeps the original.+pub fn same_bytes(a: &Path, b: &Path) -> bool {+    use std::io::Read;+    let (Ok(fa), Ok(fb)) = (std::fs::File::open(a), std::fs::File::open(b)) else { return false };+    match (fa.metadata(), fb.metadata()) {+        (Ok(ma), Ok(mb)) if ma.len() == mb.len() => {}+        _ => return false,+    }+    let (mut ra, mut rb) = (std::io::BufReader::new(fa), std::io::BufReader::new(fb));+    let (mut ba, mut bb) = (vec![0u8; 1 << 20], vec![0u8; 1 << 20]);+    loop {+        let na = match ra.read(&mut ba) {+            Ok(n) => n,+            Err(_) => return false,+        };+        if na == 0 {+            // The lengths matched, so b must be exhausted too.+            return matches!(rb.read(&mut bb[..1]), Ok(0));+        }+        if rb.read_exact(&mut bb[..na]).is_err() || ba[..na] != bb[..na] {+            return false;         }     }-    std::env::temp_dir().join("adom-bridge-swx") }  /// Where generated files go. Created on demand.@@ -79,11 +448,12 @@ pub fn exports_dir() -> PathBuf {     d } -/// Remove ONLY what the bridge itself wrote — the `exports/` subtree — best-/// effort, one item at a time. Never touches `root()` itself: on Windows that-/// is the `bridges-cache/solidworks` folder ab unzipped the RUNNING exe and-/// bridge.json into, so deleting it mid-uninstall would hit the running image-/// (ACCESS_DENIED) and leave ab's install half-removed during a /S cascade.+/// Remove ONLY what the bridge itself wrote — the `exports/` subtree and its own+/// bookkeeping files — best effort, one item at a time. Never the root itself,+/// never `recovery/`, never the ledger. (Until 0.9.0 the root WAS the+/// `bridges-cache/solidworks` folder ab unzipped the running exe into, which is+/// why this was scoped from the start; since 0.9.0 it is a data directory of its+/// own, and the scoping now protects the user's recovery files.) /// /// Returns (removed, failed) as display strings for `swx_uninstall`. pub fn remove_artifacts() -> (Vec<String>, Vec<String>) {@@ -276,6 +646,44 @@ mod tests {         let _ = std::fs::remove_dir_all(&root);     } +    #[test]+    fn is_within_matches_by_components_not_by_prefix() {+        let t = std::env::temp_dir().join(format!("swx-within-{}", std::process::id()));+        let inst = t.join("inst");+        std::fs::create_dir_all(&inst).unwrap();+        // Inside, and equal.+        assert!(is_within(&inst.join("data").join("recovery"), &inst));+        assert!(is_within(&inst, &inst));+        // A SIBLING SHARING A PREFIX is not inside.+        assert!(!is_within(&t.join("inst-data"), &inst));+        assert!(!is_within(&t, &inst), "a parent is not inside its child");+        // Spelling does not get a path out: `..`, case, the other separator.+        assert!(is_within(&t.join("x").join("..").join("inst").join("data"), &inst));+        assert!(is_within(&Path::new(&inst.to_string_lossy().to_uppercase()).join("d"), &inst));+        assert!(is_within(Path::new(&format!("{}\\data", inst.display())), &inst));+        let _ = std::fs::remove_dir_all(&t);+    }++    #[test]+    fn same_bytes_is_byte_for_byte() {+        let t = std::env::temp_dir().join(format!("swx-samebytes-{}", std::process::id()));+        std::fs::create_dir_all(&t).unwrap();+        // Larger than one read buffer, so the loop is exercised past its first+        // chunk — and the difference is placed in the SECOND chunk.+        let a: Vec<u8> = (0..(3 << 20)).map(|i| (i % 251) as u8).collect();+        let mut b = a.clone();+        std::fs::write(t.join("a"), &a).unwrap();+        std::fs::write(t.join("b"), &b).unwrap();+        assert!(same_bytes(&t.join("a"), &t.join("b")));+        b[(1 << 20) + 17] ^= 1;+        std::fs::write(t.join("b"), &b).unwrap();+        assert!(!same_bytes(&t.join("a"), &t.join("b")), "one flipped bit is not the same file");+        std::fs::write(t.join("b"), &a[..a.len() - 1]).unwrap();+        assert!(!same_bytes(&t.join("a"), &t.join("b")), "a truncated copy is not the same file");+        assert!(!same_bytes(&t.join("a"), &t.join("missing")), "a missing copy is not the same file");+        let _ = std::fs::remove_dir_all(&t);+    }+     #[test]     fn prune_dir_actually_removes_files_and_survives_a_missing_dir() {         let dir = std::env::temp_dir().join(format!("swx-prune-test-{}", std::process::id()));@@ -321,3 +729,18 @@ pub fn set_setting_bool(key: &str, value: bool) {     }     let _ = crate::launch::write_atomic(&path, &serde_json::Value::Object(s).to_string()); }++/// A file's modification time in milliseconds since the epoch, or `None` when it+/// cannot be read. Read-only. Used to record the state of a user's own file at+/// the moment a sweep closed its document in place, so a later listing can tell+/// "untouched since" from "something wrote to it since".+pub fn modified_ms(path: &str) -> Option<u64> {+    if path.trim().is_empty() {+        return None;+    }+    std::fs::metadata(path)+        .ok()+        .and_then(|m| m.modified().ok())+        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+        .map(|d| d.as_millis() as u64)+}
src/server.rs+32
@@ -62,6 +62,37 @@ pub fn run(port: Option<u16>) {         }     }; +    // MOVE THE DATA OUT OF THE INSTALL CACHE, BEFORE ANYTHING READS OR WRITES+    // IT. The legacy root is ab's install directory, which is re-extracted on+    // every update (measured 2026-09-30: every 09-17 recovery folder was wiped by+    // the 0.8.9 auto-install of 2026-09-18). It runs AFTER the bind, so a+    // duplicate instance that is about to exit never touches it, and BEFORE+    // `Ctx::new`, which restores the ownership registry from the data root — a+    // registry read before the migration would start empty, and one written+    // before it would make the migration skip the real one, since it never+    // overwrites.+    if let Some(report) = crate::scratch::migrate_legacy() {+        let copied = report["copied"].as_array().map(|a| a.len()).unwrap_or(0);+        let failed = report["failed"].as_array().map(|a| a.len()).unwrap_or(0);+        println!(+            "[swx] migrated {copied} file(s) from the legacy install cache into {} ({failed} \+             failed verification and were NOT copied; originals left in place)",+            crate::scratch::root().display()+        );+        crate::ledger::record("data_migrated_from_install_cache", report);+    }+    // AND SAY SO IF AN OVERRIDE PUT RECOVERY BACK INSIDE AN INSTALL LOCATION.+    // Every sweep refuses to write there (`recovery_inside_install_dir`); this is+    // the line in the log that explains why before anybody asks.+    if let Err(why) =+        crate::scratch::refuse_inside_install(&crate::scratch::root().join("recovery"))+    {+        println!(+            "[swx] WARNING recovery is inside an install location, so every safe-close sweep \+             will refuse: {why}"+        );+    }+     // Detection AFTER the bind — it touches the registry and shells out to     // tasklist, which is exactly the kind of slow the port must not wait on.     let detection = crate::detect::detect();@@ -72,6 +103,7 @@ pub fn run(port: Option<u16>) {     let ctx = Arc::new(Ctx::new(detection));     crate::scratch::prune(); +    println!("[swx] data root {}", crate::scratch::root().display());     println!("[swx] listening on http://{host}:{bound} — /status and /command");      let mut workers = Vec::new();
src/verbs.rs+96−17
@@ -386,10 +386,26 @@ pub(crate) fn sidecar_error(e: SidecarError) -> Value {         "errorCode": e.code,         "error": e.message,         "detail": e.detail,-        "_hint": "The COM host reported an error. If SOLIDWORKS is showing a modal dialog, \-                  dismiss it (or ask the user to) and retry; swx_status shows whether \-                  SOLIDWORKS is running and attached at all. The host process is restarted \-                  automatically on the next verb.",+        // WHAT THE FAILING CALL SAID, structured, when the host had it. A+        // translator failure diagnosed from prose that omits the HRESULT is not+        // diagnosed at all.+        "hostError": e.data,+        // A CODED REFUSAL IS ITS OWN EXPLANATION, and the generic sentence must+        // not compete with it. `interconnect_blocks_import` carried "If SOLIDWORKS+        // is showing a modal dialog, dismiss it and retry" — there is no dialog,+        // nothing is hung, and retrying changes nothing until the user acts on the+        // setting. This is the 0.7.6 defect (a blanket instruction that was wrong+        // for the specific item) in a second place, and `_hint` is the field+        // agents read first.+        "_hint": if e.code.is_some() {+            "The COM host REFUSED this, with a reason: read `errorCode` and `detail` — they say \+             what would change the answer. `hostError` carries the structured failure where there \+             was one. The host process is restarted automatically on the next verb."+        } else {+            "The COM host reported an error. If SOLIDWORKS is showing a modal dialog, dismiss it \+             (or ask the user to) and retry; swx_status shows whether SOLIDWORKS is running and \+             attached at all. The host process is restarted automatically on the next verb."+        },     }) } @@ -1129,7 +1145,16 @@ impl StopEvidence {                 }                 dialog_pids = pids;                 census_readable = all_read;-                dialog = quit::first_blocking_dialog(&windows);+                // READ WHAT IT SAYS, here too. 0.8.4 gave the SWEEP the dialog's+                // own words and left the LADDER reporting a title — and the+                // ladder is where a stuck session gets diagnosed. A title cannot+                // tell "your licence expired" from "the licence server is+                // unreachable and this session ends after five more tries".+                // Reading is not answering; nothing here presses anything.+                dialog = quit::first_blocking_dialog(&windows).map(|mut d| {+                    d.read_contents(os);+                    d+                });             }         }         // P5: responding, without COM.@@ -1244,11 +1269,37 @@ fn swx_stop(ctx: &Ctx, args: &Value) -> Value {     // ── THE LADDER PLAN. Pinned at rung "a" by default: rungs (b)-(f) send     //    window messages and run VBA inside somebody's CAD session and not one     //    of them has been observed on a real machine.+    // ── DREW'S STANDING AUTHORIZATION, 2026-09-11. `userAuthorized:true` says the+    //    human has already answered the question this verb used to refuse for:+    //    whether the bridge may end their SOLIDWORKS. It changes exactly two+    //    things and nothing else.+    //+    //      * the ABSENCE of a launch attribution stops being a blocker — a+    //        session the bridge did not launch may still be quit, because the+    //        user said so. (The identity MISMATCH guard stays: a claim that names+    //        a different process still refuses, because that is not consent, it+    //        is a bookkeeping error.)+    //      * rungs (c) and (d) become available, since COM can be wedged behind a+    //        licence dialog on a locked desktop and those two need no COM at all.+    //+    //    It does NOT waive: the zero-open-documents rule, the dialog rule, the+    //    busy rule, rung (f) without allowRestartManager, or the law that nothing+    //    here ever kills anything.+    let user_authorized = arg_bool(args, "userAuthorized", false);     let max_rung = args         .get("maxRung")         .and_then(|v| v.as_str())-        .unwrap_or(quit::DEFAULT_MAX_RUNG)+        .unwrap_or(if user_authorized { "a" } else { quit::DEFAULT_MAX_RUNG })         .to_string();+    if !user_authorized && matches!(max_rung.as_str(), "c" | "d") {+        return err_code(+            "rung_needs_user_authorization",+            format!(+                "rung '{max_rung}' sends a window message into the user's SOLIDWORKS, which needs                  their say-so: pass userAuthorized:true with a reason"+            ),+            "The window-message rungs act on the application's own frame rather than through COM.              They are available when the user has authorized the bridge to close SOLIDWORKS —              `userAuthorized:true` — which is also what makes them useful, because a session wedged              behind a licence dialog cannot be reached through COM at all.",+        );+    }     let plan = quit::rungs_up_to(&max_rung);     if plan.is_empty() {         return err_code(@@ -1336,6 +1387,16 @@ fn swx_stop(ctx: &Ctx, args: &Value) -> Value {      let job = ctx.jobs.start("app_stop", "Asking SOLIDWORKS to quit (measured ladder)", 60);     let census_before = census0.as_ref().ok().cloned().unwrap_or_default();+    if user_authorized {+        crate::ledger::record(+            "solidworks_quit_authorized",+            json!({+                "authority": "quit under Drew's standing authorization (2026-09-11)",+                "reason": arg_str(args, "reason"),+                "maxRung": max_rung,+            }),+        );+    }     crate::ledger::record(         "solidworks_quit_started",         json!({@@ -1500,7 +1561,12 @@ fn swx_stop(ctx: &Ctx, args: &Value) -> Value {             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));+            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;@@ -2670,8 +2736,9 @@ fn swx_uninstall(ctx: &Ctx, args: &Value) -> Value {     ctx.com.shutdown();     let mut removed: Vec<String> = vec!["com host process".to_string()]; -    // ONLY the bridge's own exports subtree, best-effort per item — never the-    // cache root ab unzipped the running exe into.+    // ONLY the bridge's own bookkeeping and exports subtree, best-effort per+    // item — never the data root itself, never its recovery folders, never the+    // ledger.     let (mut artifacts, failed) = crate::scratch::remove_artifacts();     removed.append(&mut artifacts); @@ -2681,14 +2748,18 @@ fn swx_uninstall(ctx: &Ctx, args: &Value) -> Value {         "failed": failed,         "cascade": arg_bool(args, "cascade", false),         "ledgerPreserved": true,-        "_hint": "Idempotent, non-interactive, best-effort. The bridge writes only its own \-                  exports cache, so that subtree is all this removes — the ab cache root, \-                  SOLIDWORKS itself, its settings and every user document are never touched. A \-                  file still locked open is reported in `failed`, not fought over. \-                  swx_action_ledger's log is DELIBERATELY KEPT: this verb is ungated (ab calls \-                  it on app uninstall), so deleting the audit trail here would let a caller \-                  erase the record of what it did by asking the bridge to tidy up. Removing the \-                  folder is the user's to do.",+        // A recovery file can be the ONLY copy of work the user had not saved.+        "recoveryPreserved": true,+        "dataRoot": crate::scratch::root().to_string_lossy(),+        "_hint": "Idempotent, non-interactive, best-effort. This removes the bridge's own \+                  bookkeeping and its exports subtree, and nothing else: the data root itself, \+                  ab's install cache, SOLIDWORKS, its settings and every user document are \+                  never touched. A file still locked open is reported in `failed`, not fought \+                  over. The RECOVERY FOLDERS are DELIBERATELY KEPT — a rescued file can be the \+                  only copy of work the user had not saved — and so is swx_action_ledger's log: \+                  this verb is ungated (ab calls it on app uninstall), so deleting the audit \+                  trail here would let a caller erase the record of what it did by asking the \+                  bridge to tidy up. Removing `dataRoot` is the user's to do.",     }) } @@ -2732,6 +2803,9 @@ pub const CATALOG: &[(&str, &str, &str)] = &[     ("swx_close_all_documents", "exec", "close EVERY open document (confirm:true; skips unsaved by default)"),     ("swx_reset_workspace", "exec", "close only the scratch documents this bridge created"),     ("swx_close_import_temps", "exec", "drain leaked STEP/IGES import temporaries (…\\Temp\\swx<pid>\\IC~~\\…); default posture, never a saved user doc"),+    ("swx_close_all_safely", "exec", "save EVERY open document into a recovery folder, then close it — nothing closes before it is on disk; needs only a reason"),+    ("swx_shutdown", "exec", "close_all_safely, then quit SOLIDWORKS; idempotent, and the quit is skipped unless the session really emptied"),+    ("swx_recover_list", "read", "what previous sweeps preserved, and where — newest first"),     ("swx_kill", "process", "force-kill SOLIDWORKS (reason + confirm; discards unsaved work)"),     ("swx_window_info", "read", "SOLIDWORKS' windows and its main hwnd (read-only, nothing raised)"),     ("swx_dismiss_blocking_dialogs", "exec", "census of blocking SOLIDWORKS dialogs; close:true is opt-in, gated, and never touches an unattributed window"),@@ -2822,6 +2896,11 @@ pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value {         "swx_progress" => swx_progress,         "swx_start" => swx_start,         "swx_stop" => swx_stop,+        // DREW'S STANDING AUTHORIZATION, 2026-09-11: the bridge closes documents+        // and quits by itself, having first written every document to disk.+        "swx_close_all_safely" => crate::verbs3::close_all_safely,+        "swx_shutdown" => crate::verbs3::shutdown,+        "swx_recover_list" => crate::verbs3::recover_list,         "swx_docs" => swx_docs,         "swx_find_files" => swx_find_files,         "swx_open" => swx_open,
src/verbs3.rs+1518−14
@@ -376,6 +376,1445 @@ pub fn activate_document(ctx: &Ctx, args: &Value) -> Value {     } } +/// THE RECOVERY FOLDER for one sweep: `<scratch>/recovery/<yyyymmdd-hhmmss>/`.+///+/// One folder per sweep, named by the clock, so a second sweep never lands in the+/// first one's directory and `swx_recover_list` can show the user what came from+/// when.+/// WHAT TO TELL THE USER ABOUT ONE FILE, given a verdict somebody actually made.+fn how_to_open(verified: bool, kind_known: bool, reason: &str, guessed_name: bool) -> String {+    if !verified {+        return format!(+            "NOT A RESCUE. The sweep declined to close against this file: {}. The document it \+             came from was left OPEN — if it is still open, its work is in SOLIDWORKS, not here. \+             These bytes are kept because they are the only ones there are, not because they are \+             a complete document.",+            if reason.is_empty() { "it could not be verified" } else { reason }+        );+    }+    if !kind_known {+        return format!(+            "SOLIDWORKS will not open this file under this name{}. RENAME it to .SLDPRT, .SLDASM \+             or .SLDDRW — the kind could not be established when it was written, so try them in \+             that order — then swx_open the renamed file. The bytes were verified as a document; \+             only the name is unknown.",+            if guessed_name {+                " — its extension was a GUESS made when the document's type could not be read, \+                 not something this bridge observed"+            } else {+                ""+            }+        );+    }+    "swx_open this path.".to_string()+}++/// A FILE WITH NO RECORD BESIDE IT.+///+/// Older sweeps wrote no manifest, and a manifest can be lost. What can still be+/// checked is checked — but the result is NOT the same answer the sweep would+/// have given, and must not be dressed as one:+///+///   * the bytes are not a document of any kind: that is CONCLUSIVE, and false.+///   * the bytes pass: that is NOT conclusive. This verb cannot know what the+///     original weighed, nor whether the extension was a guess, and both of those+///     are what the unknown-kind rules turn on. So the honest answer is UNKNOWN —+///     `null` — because a weaker check does not produce false negatives, it+///     produces FALSE POSITIVES, and `verified:true` for a file the sweep refused+///     is the one error that costs the user something.+fn reverified_entry(+    p: &std::path::Path,+    name: &str,+    bytes: Option<u64>,+    kind: Option<crate::disk::DocKind>,+    v: &crate::comhost::host::RecoveryVerdict,+) -> Value {+    let conclusive_no = !v.ok;+    json!({+        "path": p.to_string_lossy(),+        "name": name,+        "bytes": bytes,+        "source": "reverified",+        "kind": kind.map(|k| json!(k.type_name())).unwrap_or(Value::Null),+        "kindKnown": kind.is_some(),+        "signatureOk": v.signature_ok,+        "verified": if conclusive_no { json!(false) } else { Value::Null },+        "verifiedReason": if conclusive_no {+            v.why.clone().map(Value::from).unwrap_or(Value::Null)+        } else {+            json!("re-checked from the file alone: this sweep left no manifest, so this bridge \+                   cannot see what the original document weighed or whether the extension was a \+                   guess — both of which the sweep's own verdict turned on. The bytes look like a \+                   document; whether the sweep accepted them is UNKNOWN.")+        },+        "howToOpen": if conclusive_no {+            format!(+                "NOT A RESCUE. These bytes are not a complete document: {}. If the document is \+                 still open in SOLIDWORKS, its work is there rather than here.",+                v.why.clone().unwrap_or_else(|| "it could not be verified".into())+            )+        } else {+            "UNCONFIRMED. No manifest was left for this sweep, so this bridge cannot say whether \+             it vouched for this file. Try swx_open (renaming a `.sldrecover` file to .SLDPRT, \+             .SLDASM or .SLDDRW first); if SOLIDWORKS refuses it, the file is incomplete and the \+             document's work is wherever it was left."+                .to_string()+        },+    })+}++/// A HOST FAILURE, WITH ITS STRUCTURE INTACT.+///+/// The sweep used to build `{"why": "the com host errored: <message>"}` at five+/// call sites and drop `e.data` at every one — so the structured COM detail 0.7.8+/// exists to surface could not reach a caller from this verb no matter what the+/// host sent. That matters more here than anywhere else: this is the verb that+/// runs when SOLIDWORKS is already misbehaving, so it meets the HRESULTs worth+/// reading, and since 0.7.8 its entries are written into `manifest.json` and+/// KEPT — a prose-only verdict is the permanent record of why a document was not+/// preserved.+fn host_failure_entry(e: &crate::comhost::SidecarError, path: Option<&str>) -> Value {+    json!({+        "path": path,+        "why": format!("the com host errored: {}", e.message),+        "detail": e.detail,+        "errorCode": e.code,+        "timedOut": e.timed_out,+        // THE STRUCTURED REASON. Where the host answered with one, it is carried+        // verbatim. Where it did NOT answer at all — a timeout, which kills the+        // com host — there is no host detail to carry, and `null` there is+        // indistinguishable from having thrown the detail away. So a timeout+        // reports ITS OWN facts instead: what this was, that the budget was+        // exceeded, and that the host was killed and respawns. Both are real+        // structure; neither is invented.+        "hostError": e.data.clone().unwrap_or_else(|| {+            if e.timed_out {+                json!({+                    "failure": "host_timeout",+                    "timedOut": true,+                    "message": e.message,+                    "note": "the com host did not answer within its budget and was killed; it \+                             respawns on the next verb. No COM detail exists for this failure \+                             because SOLIDWORKS never returned one.",+                })+            } else {+                Value::Null+            }+        }),+    })+}++/// THE NAME OF THE SWEEP'S OWN RECORD, written beside the files it wrote.+pub const MANIFEST_NAME: &str = "manifest.json";++/// WHAT THE SWEEP DECIDED, WRITTEN DOWN WHERE IT DECIDED IT.+///+/// Everything `swx_recover_list` used to re-derive was in hand at save time and+/// thrown away when the process ended: the original document's size, the kind+/// `GetType` reported, and whether the file's extension was a GUESS+/// (`namingFallback`). Re-deriving a verdict without them is strictly weaker than+/// the verdict it is trying to reproduce — and a weaker check does not produce+/// false negatives, it produces FALSE POSITIVES. `verified:true` for a file the+/// sweep refused and whose document it left open is the one error that matters+/// here, because the person reading the listing is the person whose sweep just+/// failed.+///+/// So the sweep writes its record into its own folder. It is rewritten after+/// every save and every close, ATOMICALLY (a temporary file and a rename), so a+/// bridge that dies mid-sweep leaves a manifest describing everything decided up+/// to that moment rather than a truncated one — and that is the case that matters+/// most, because a sweep that ran during a crash is exactly the one whose files+/// somebody reads later from a fresh bridge.+fn write_manifest(+    dir: &std::path::Path,+    reason: &str,+    records: &[Value],+    closed: &[String],+    failures: &[Value],+) {+    let files: Vec<Value> = records+        .iter()+        .map(|r| {+            let rp = r.get("recoveryPath").and_then(|v| v.as_str()).unwrap_or("");+            let name = std::path::Path::new(rp)+                .file_name()+                .map(|n| n.to_string_lossy().to_string())+                .unwrap_or_default();+            // A DOCUMENT CLOSED IN PLACE. A clean, saved document needs no copy —+            // its own file IS its preservation — so the sweep writes nothing for+            // it and `recoveryPath` is null. Without saying so here, the record+            // had no entry a reader could find, and swx_recover_list could not+            // tell a user "your file was closed at its own path, untouched",+            // which is the most reassuring thing it has to say. Measured live+            // 2026-09-30: Drew's saved part was closed with its bytes and mtime+            // unchanged, and the listing had nothing to show for it.+            let original = r.get("path").and_then(|v| v.as_str()).unwrap_or("");+            let in_place = rp.is_empty()+                && r.get("preserved").and_then(|v| v.as_bool()) == Some(true)+                && !original.is_empty();+            let close_key = if in_place { original } else { rp };+            json!({+                "name": if in_place { Value::Null } else { json!(name) },+                "closedInPlace": in_place,+                "recoveryPath": r.get("recoveryPath").cloned().unwrap_or(Value::Null),+                "title": r.get("title").cloned().unwrap_or(Value::Null),+                "originalPath": r.get("path").cloned().unwrap_or(Value::Null),+                // The kind SOLIDWORKS reported, or null when nothing could+                // establish it. A reader must not infer this from the extension.+                "kind": r.get("docKind").cloned().unwrap_or(Value::Null),+                // THE FACT NO FILE NAME CAN CARRY: this extension was a guess.+                "namingFallback": r.get("namingFallback").cloned().unwrap_or(Value::Null),+                "originalBytes": r.get("originalBytes").cloned().unwrap_or(Value::Null),+                // The user's own file's modification time when the sweep reached+                // it — recorded for a document closed in place, so a later reader+                // can tell "untouched since" from "written to since".+                "originalModifiedMs": r.get("originalModifiedMs").cloned().unwrap_or(Value::Null),+                "recoveryBytes": r.get("recoveryBytes").cloned().unwrap_or(Value::Null),+                "signatureOk": r.get("recoverySignatureOk").cloned().unwrap_or(Value::Null),+                // THE SWEEP'S VERDICT AND ITS OWN WORDS FOR IT. `verified` is a+                // verdict about bytes THIS SWEEP WROTE, so a document closed in+                // place has none: null, not true — nothing was written to verify.+                "verified": if in_place {+                    Value::Null+                } else {+                    r.get("preserved").cloned().unwrap_or(Value::Null)+                },+                "verdictReason": match r.get("why").filter(|v| !v.is_null()) {+                    Some(w) => w.clone(),+                    None if in_place => json!(+                        "clean and saved when the sweep reached it: its own file is its \+                         preservation, so the sweep wrote no copy and closed it at its own path \+                         without writing to that file"+                    ),+                    None => Value::Null,+                },+                "preserved": r.get("preserved").cloned().unwrap_or(Value::Null),+                // CLOSED BY THE PATH IT WAS CLOSED BY — its own, for a document+                // closed in place.+                "closed": !close_key.is_empty()+                    && closed.iter().any(|c| crate::verbs::same_path(c, close_key)),+                "wasDirty": r.get("wasDirty").cloned().unwrap_or(Value::Null),+                "wasPathless": r.get("wasPathless").cloned().unwrap_or(Value::Null),+            })+        })+        .collect();+    let doc = json!({+        "manifestVersion": 1,+        // WHY THE SWEEP STOPPED SHORT, with whatever structure the host gave.+        // A host failure explains documents that have NO file here at all, which+        // is exactly the question somebody reading this folder later is asking —+        // and it was the one thing the record did not answer.+        "failures": failures,+        "sweep": dir.file_name().map(|n| n.to_string_lossy().to_string()),+        "reason": reason,+        "updatedAtMs": crate::launchos::epoch_ms() as u64,+        "bridgeVersion": crate::BRIDGE_VERSION,+        "files": files,+        "_note": "Written by swx_close_all_safely / swx_shutdown as it ran. `verified:false` means \+                  this bridge REFUSED to close the document against these bytes and left the \+                  document open — the work is in SOLIDWORKS, not in this file. Kept because these \+                  are the only bytes there are. `closedInPlace:true` means the document was clean \+                  and saved: no copy was written (`name` and `recoveryPath` are null), and it was \+                  closed at `originalPath`, a file this sweep never writes to.",+    });+    let Ok(text) = serde_json::to_string_pretty(&doc) else { return };+    // ATOMIC: a half-written manifest would be worse than none, because the+    // listing believes it.+    let tmp = dir.join("manifest.json.tmp");+    if std::fs::write(&tmp, text.as_bytes()).is_ok() {+        let _ = std::fs::rename(&tmp, dir.join(MANIFEST_NAME));+    }+}++fn recovery_dir() -> std::path::PathBuf {+    let now = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_secs())+        .unwrap_or(0);+    // yyyymmdd-hhmmss without pulling in a date library: the epoch arithmetic is+    // exact and this only has to be unique and sortable.+    let days = now / 86_400;+    let secs = now % 86_400;+    let (mut y, mut d) = (1970u64, days);+    loop {+        let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;+        let len = if leap { 366 } else { 365 };+        if d < len {+            break;+        }+        d -= len;+        y += 1;+    }+    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;+    let months = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];+    let (mut m, mut day) = (0usize, d);+    while m < 12 && day >= months[m] {+        day -= months[m];+        m += 1;+    }+    let stamp = format!(+        "{y:04}{:02}{:02}-{:02}{:02}{:02}",+        m + 1,+        day + 1,+        secs / 3600,+        (secs % 3600) / 60,+        secs % 60+    );+    // ONE FOLDER PER SWEEP, even two sweeps in the same second.+    //+    // The stamp is second-granular, so two sweeps a fraction apart used to SHARE+    // a folder — and since 0.7.8 that means sharing a `manifest.json`. The second+    // sweep's manifest overwrites the first's, so the first sweep's files are+    // still there with no record describing them; and if the second preserved+    // nothing, its cleanup DELETED the first's manifest outright. That is the+    // mechanism behind the intermittent+    // `a_sweep_records_its_verdicts_beside_the_files_it_wrote` failure — roughly+    // one run in twelve — and it is a real reporting defect, not a test artefact:+    // the record of why a user's document was not preserved is what goes missing.+    // CLAIMED ATOMICALLY, not check-then-create. `if !exists { create }` is a+    // race with a window between the two halves: two sweeps starting together+    // both see "free" and both take the same folder — which is the defect this+    // uniqueness was added to fix, reintroduced by the way it was implemented.+    // `create_dir` FAILS if the directory is already there, so the creation IS+    // the claim, and AlreadyExists simply means take the next name.+    let base = crate::scratch::root().join("recovery");+    let _ = std::fs::create_dir_all(&base);+    for n in 0..1000 {+        let d = if n == 0 { base.join(&stamp) } else { base.join(format!("{stamp}-{}", n + 1)) };+        match std::fs::create_dir(&d) {+            Ok(()) => return d,+            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,+            // Anything else — no permission, no disk — is not a naming problem,+            // and the sweep's own headroom and write checks report it properly.+            Err(_) => {+                let _ = std::fs::create_dir_all(&d);+                return d;+            }+        }+    }+    let d = base.join(format!("{stamp}-{}", crate::launchos::epoch_ms()));+    let _ = std::fs::create_dir_all(&d);+    d+}++/// IS A DIALOG ON SCREEN? The sweep must never reach a save prompt — every+/// document is clean before anything closes — so one appearing means a premise+/// is wrong, and the answer is to STOP and report it, never to answer it.+///+/// `None` means the census could not be taken, which also stops: a dialog we+/// cannot see is the one that matters.+fn blocking_dialog_now(ctx: &Ctx) -> Option<Option<Value>> {+    let os = crate::launchos::os();+    let state = ctx.call("app_state", json!({}), 20);+    let pid = state+        .as_ref()+        .ok()+        .and_then(|s| s.get("processId"))+        .and_then(|v| v.as_u64())+        .map(|p| p as u32)?;+    let census = os.processes().ok()?;+    let id = crate::quit::Identity {+        pid,+        created_at: census.iter().find(|p| p.pid == pid).and_then(|p| p.created_at)?,+        main_hwnd: None,+    };+    let pids = crate::quit::dialog_census_pids(Ok(&census), &id)?;+    let mut windows: Vec<crate::launchos::WindowInfo> = Vec::new();+    for p in &pids {+        match os.windows_of_pid(*p) {+            Ok(w) => windows.extend(w),+            Err(_) => return None,+        }+    }+    // AND READ WHAT IT SAYS. The sweep stops on a dialog and reports it; a title+    // alone cannot tell "your licence expired" from "the licence server is+    // unreachable and the session ends after five more tries". Reading is not+    // answering — nothing here presses anything.+    Some(crate::quit::first_blocking_dialog(&windows).map(|mut d| {+        d.read_contents(os.as_ref());+        d.to_json()+    }))+}++/// `swx_close_all_safely` — PRESERVE EVERYTHING, THEN CLOSE EVERYTHING.+///+/// Drew, 2026-09-11: *"I despise every time you ask for help closing. You MUST+/// find/build a way to safely close the docs/program."*+///+/// So this verb does not ask. It does not check ownership either — the user+/// authorized closing their own documents, and whose document it is stops being+/// the question. What replaces the question is a SEQUENCE, and it is the whole+/// safety property of this verb:+///+///   **every document is written to disk and verified there BEFORE anything+///   closes, and a document that could not be written is not closed.**+///+/// A dirty document is SAVED TO RECOVERY — `SaveAs3`, Silent alone, never Copy —+/// so the open document then POINTS AT the recovery file. That re-point is not a+/// detail: it is what makes the close key right, because the close is by the path+/// the document has at that moment. The user's original file on disk is never+/// written to; it is simply no longer what this in-memory document is attached+/// to, and their unsaved changes now live in recovery.+///+/// A save prompt cannot appear, because nothing is unsaved by the time a close+/// happens. If any dialog DOES appear the sweep stops and reports it rather than+/// answering it — and if the window census cannot be READ, the saves still happen+/// (they lose nothing) while the closes do not (they cannot be taken back).+pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {+    gate!(ctx);+    if let Some(g) = require_reason(+        args,+        "swx_close_all_safely",+        "it closes every open SOLIDWORKS document after saving each one to a recovery folder",+    ) {+        return g;+    }+    if let Err(e) = identity_claim_ok(ctx) {+        return e;+    }+    // NOT UNDER THE INSTALL DIRECTORY. A recovery folder is the user's only copy+    // of unsaved work, and the install directory is re-extracted on update —+    // measured 2026-09-30, when an auto-install deleted every recovery folder+    // from the day before. The data root is outside it by construction; this is+    // the check that refuses, loudly and before anything is written or closed, if+    // an override or a future change ever puts it back inside.+    if let Err(why) = crate::scratch::refuse_inside_install(&crate::scratch::root().join("recovery")) {+        return err_code(+            "recovery_inside_install_dir",+            why,+            "Nothing was preserved and nothing was closed. Point the bridge's data root \+             somewhere an update does not touch (the default is %LOCALAPPDATA%\\Adom \+             Bridge\\solidworks-data) and run this again.",+        );+    }+    let job = ctx.jobs.start("close_all_safely", "Saving every document, then closing it", 120);+    let dir = recovery_dir();+    let dir_s = dir.to_string_lossy().to_string();+    let started = std::time::Instant::now();+    let budget = std::time::Duration::from_secs(+        args.get("maxSeconds").and_then(|v| v.as_u64()).unwrap_or(240).clamp(5, 580),+    );+    let batch = args.get("batch").and_then(|v| v.as_u64()).unwrap_or(25).clamp(1, 200);++    // ── HOW MUCH ROOM IS THERE, AND HOW MUCH DOES THIS NEED?+    //+    //    Marker and floor cannot catch the one failure that matters most here:+    //    THE RIGHT FILE, CUT SHORT. The marker lives at offset 4, so any prefix+    //    of a real document carries it, and a write that reaches 20 KB of a+    //    500 KB assembly passes the floor too. The format offers nothing cheap+    //    that would catch it — no constant tail, no length field (measured). So+    //    the defence is at the START: a truncation this bridge could not detect+    //    afterwards is one it declines to cause.+    //    THE KIND OF EACH DOCUMENT COMES FROM SOLIDWORKS, not from a guess: an+    //    assembly is about thirty times the median part, and costing a pathless+    //    one as if it were a part is how the per-save gate passes with far less+    //    room than the write needs.+    let open_docs: Vec<(String, Option<crate::disk::DocKind>)> =+        match ctx.call("docs", json!({}), 60) {+            Ok(d) => d+                .get("docs")+                .and_then(|v| v.as_array())+                .map(|a| {+                    a.iter()+                        .map(|x| {+                            let path =+                                x.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string();+                            // The real backend reports `type`, the simulator+                            // `docType`; a TITLE is the last resort, and it is+                            // read as a title — its extension, or the prefix+                            // SOLIDWORKS gives an unsaved document ("Assem1").+                            // 0.7.4 ran the title through a substring match that+                            // called `Assem1` a part and costed it at 256 KB,+                            // which is worse than the 1 MB it would have cost+                            // with no hint at all. A document whose kind cannot+                            // be established stays None and costs the generic+                            // unknown.+                            let kind = x+                                .get("type")+                                .and_then(|v| v.as_str())+                                .and_then(|t| crate::disk::DocKind::from_type_name(t))+                                .or_else(|| {+                                    x.get("docType")+                                        .and_then(|v| v.as_str())+                                        .and_then(|t| crate::disk::DocKind::from_type_name(t))+                                })+                                .or_else(|| {+                                    x.get("title")+                                        .and_then(|v| v.as_str())+                                        .and_then(|t| crate::disk::DocKind::from_title(t))+                                });+                            (path, kind)+                        })+                        .collect()+                })+                .unwrap_or_default(),+            // An unreadable session is not an empty one. Estimate nothing from it+            // and let the per-save check carry the weight.+            Err(_) => Vec::new(),+        };+    let open_paths: Vec<String> = open_docs.iter().map(|(p, _)| p.clone()).collect();+    let estimate = crate::disk::estimate_writes_typed(&open_docs);+    let needed = estimate.total.saturating_mul(crate::disk::HEADROOM_FACTOR);+    let per_save_needed = estimate.largest.saturating_mul(crate::disk::HEADROOM_FACTOR);+    let free_before = crate::disk::free_bytes(&dir);+    let mut stopped_by_headroom: Option<Value> = None;+    // UNKNOWN FREE SPACE IS NOT PERMISSION, but it is not a refusal either: this+    // sweep's other guarantees do not depend on it, and refusing every sweep on a+    // volume this process cannot stat would put the bridge back to asking the+    // user to close things by hand. It is reported, and it is why+    // `headroomBytesBefore` can be null.+    if let Some(free) = free_before {+        if free < needed {+            stopped_by_headroom = Some(json!({+                "freeBytes": free,+                "neededBytes": needed,+                "shortfallBytes": needed.saturating_sub(free),+                "when": "before anything was written",+            }));+        }+    }++    let mut recovered: Vec<Value> = Vec::new();+    let mut closed_paths: Vec<String> = Vec::new();+    let mut close_failed: Vec<Value> = Vec::new();+    let mut closed = 0u64;+    let mut before: Option<i64> = None;+    let mut after: Option<i64> = None;+    let mut done = false;+    let mut stopped_by_dialog: Option<Value> = None;+    let mut dialog_unreadable = false;+    // THE CENSUS, ASKED AGAIN. Called before EVERY save and before EVERY close,+    // not once per batch: a save can raise a prompt of its own (a missing+    // external reference, a rebuild question), and under the old once-per-batch+    // check a dialog raised by the first save stayed invisible until every other+    // document in the batch had already been closed.+    //+    //   * `Some(Some(d))` — a dialog. STOP; it is never answered.+    //   * `None` — nobody can see. The SAVES still run, because saving loses+    //     nothing and is the half that protects the user's work; the CLOSES do+    //     not, because a dialog nobody can see is exactly the one that matters.+    //     Same rule as swx_stop's P3.+    //   * `Some(None)` — clear.+    enum Look {+        Clear,+        Blind,+        Dialog(Value),+    }+    let look = |ctx: &Ctx| -> Look {+        match blocking_dialog_now(ctx) {+            None => Look::Blind,+            Some(Some(d)) => Look::Dialog(d),+            Some(None) => Look::Clear,+        }+    };+    'sweep: while stopped_by_headroom.is_none() {+        // ── PHASE 1: PRESERVE, one document at a time, censusing before each.+        //    Nothing closes during this phase, which is what makes the walk index+        //    a stable way to step through the session.+        // (path, kind, was-written-by-this-sweep, the original's size)+        let mut ready: Vec<(String, Option<crate::disk::DocKind>, bool, Option<u64>)> = Vec::new();+        let mut walked = 0u64;+        let mut index = 0u64;+        while index < batch {+            match look(ctx) {+                Look::Dialog(d) => {+                    stopped_by_dialog = Some(d);+                    break 'sweep;+                }+                Look::Blind => dialog_unreadable = true,+                Look::Clear => {}+            }+            if started.elapsed() >= budget {+                break;+            }+            // AGAIN, BEFORE EVERY SAVE. The disk can fill while this runs — this+            // sweep is itself one of the things filling it — and the check that+            // matters is the one taken before the write that would be truncated,+            // not the one taken before the first of them.+            if let Some(free) = crate::disk::free_bytes(&dir) {+                if free < per_save_needed {+                    stopped_by_headroom = Some(json!({+                        "freeBytes": free,+                        "neededBytes": per_save_needed,+                        "shortfallBytes": per_save_needed.saturating_sub(free),+                        "when": format!("after preserving {} document(s)", recovered.len()),+                    }));+                    break 'sweep;+                }+            }+            let r = match ctx.call(+                "preserve_and_close",+                json!({"phase": "preserve", "recoveryDir": dir_s, "index": index}),+                300,+            ) {+                Err(e) => {+                    close_failed.push(host_failure_entry(&e, None));+                    // RECORD IT. A sweep that stops because the HOST failed is+                    // exactly the case where the folder holds fewer files than+                    // documents, and the manifest is the only thing that can say+                    // why.+                    write_manifest(+                        &dir,+                        arg_str(args, "reason").unwrap_or_default(),+                        &recovered,+                        &closed_paths,+                        &close_failed,+                    );+                    break 'sweep;+                }+                Ok(r) => r,+            };+            if before.is_none() {+                before = r.get("documentCount").and_then(|v| v.as_i64());+            }+            after = r.get("documentCount").and_then(|v| v.as_i64());+            if r.get("walked").and_then(|v| v.as_u64()).unwrap_or(0) == 0 {+                break; // the walk is exhausted+            }+            walked += 1;+            if let Some(rec) = r.get("record").filter(|v| !v.is_null()) {+                recovered.push(rec.clone());+                // AFTER EVERY SAVE. A sweep that dies here leaves a record of+                // everything decided up to this document.+                write_manifest(&dir, arg_str(args, "reason").unwrap_or_default(), &recovered, &closed_paths, &close_failed);+            }+            if let Some(rd) = r.get("ready").filter(|v| !v.is_null()) {+                ready.push((+                    rd.get("path").and_then(|v| v.as_str()).unwrap_or_default().to_string(),+                    // The KIND the backend established, or None when it could+                    // not. It is carried, never re-derived from the file name —+                    // that name came FROM the kind.+                    rd.get("docType")+                        .and_then(|v| v.as_str())+                        .and_then(|t| crate::disk::DocKind::from_type_name(t)),+                    rd.get("writtenHere").and_then(|v| v.as_bool()).unwrap_or(false),+                    rd.get("originalBytes").and_then(|v| v.as_u64()),+                ));+            }+            index += 1;+        }++        // ── PHASE 2: CLOSE. Drawings and assemblies before parts — a part an+        //    assembly still references stays open when closed on its own, and+        //    closing the parent first lets the child go. Skipped entirely while+        //    the census is unreadable: everything is already safe on disk.+        //    A document of UNKNOWN kind is closed LAST: it might be a part held+        //    open by an assembly, and going late costs nothing.+        ready.sort_by_key(|(_, k, _, _)| match k {+            Some(crate::disk::DocKind::Drawing) => 0,+            Some(crate::disk::DocKind::Assembly) => 1,+            Some(crate::disk::DocKind::Part) => 2,+            None => 3,+        });+        let mut closed_this_pass = 0u64;+        if !dialog_unreadable {+            for (path, kind, written_here, original_bytes) in &ready {+                match look(ctx) {+                    Look::Dialog(d) => {+                        stopped_by_dialog = Some(d);+                        break 'sweep;+                    }+                    // BLIND MID-SWEEP. Whatever was closed is closed and was on+                    // disk first; nothing further closes.+                    Look::Blind => {+                        dialog_unreadable = true;+                        break;+                    }+                    Look::Clear => {}+                }+                match ctx.call(+                    "preserve_and_close",+                    json!({+                        "phase": "close", "path": path, "writtenHere": written_here,+                        "docKind": kind.map(|k| json!(k.type_name())).unwrap_or(Value::Null),+                        "originalBytes": original_bytes,+                    }),+                    300,+                ) {+                    Err(e) => {+                        close_failed.push(host_failure_entry(&e, Some(path)));+                        break 'sweep;+                    }+                    Ok(r) => {+                        after = r.get("documentCount").and_then(|v| v.as_i64());+                        if r.get("closed").and_then(|v| v.as_bool()).unwrap_or(false) {+                            closed += 1;+                            closed_this_pass += 1;+                            closed_paths.push(path.clone());+                            // AND AFTER EVERY CLOSE, so the record says which+                            // documents are gone from SOLIDWORKS and now exist+                            // only as these files.+                            write_manifest(+                                &dir,+                                arg_str(args, "reason").unwrap_or_default(),+                                &recovered,+                                &closed_paths,+                                &close_failed,+                            );+                        } else {+                            close_failed.push(json!({+                                "path": path,+                                "why": r.get("why").and_then(|v| v.as_str())+                                    .unwrap_or("still open after CloseDoc"),+                            }));+                        }+                    }+                }+            }+        }+        // DONE means the session is EMPTY and we can prove it — not that this+        // pass finished.+        done = !dialog_unreadable && after == Some(0) && close_failed.is_empty();+        // A preserve-only pass closes nothing by design, so it runs ONCE.+        // NO PROGRESS IS A STOP, NOT A LOOP: a pass that walked documents and+        // closed none will close none next time either — the commonest cause is+        // that they could not be saved, which is exactly when this verb must+        // leave them alone and say so.+        if done || walked == 0 || dialog_unreadable || closed_this_pass == 0+            || started.elapsed() >= budget+        {+            break;+        }+    }+    // AN EMPTY RESCUE FOLDER IS NOT A RESCUE. The folder is created before the+    // sweep knows whether it may run; a refusal must not leave one behind for+    // swx_recover_list to present as preserved work, or for the next reader to+    // mistake for a sweep that lost everything. `remove_dir` removes it only if+    // it is empty, so this can never delete a rescue.+    // A refusal that wrote nothing leaves no folder — and no manifest, since a+    // A refusal that wrote nothing leaves no folder — and no manifest, since a+    // manifest describing nothing is a rescue folder that looks like a rescue.+    // BUT A FAILURE IS SOMETHING TO SAY: once the record carries `failures`, a+    // sweep that preserved nothing because the host broke has the most important+    // thing to explain and no file to explain it beside, so the record is kept+    // whenever there is a failure in it.+    if recovered.is_empty() && close_failed.is_empty() {+        let _ = std::fs::remove_file(dir.join(MANIFEST_NAME));+    }+    let _ = std::fs::remove_dir(&dir);+    crate::ledger::record(+        "close_all_safely",+        json!({+            "recoveryDir": dir_s,+            "preserved": recovered.len(),+            "closed": closed,+            "reason": arg_str(args, "reason"),+            "authority": "Drew's standing authorization, 2026-09-11: the bridge closes documents \+                          itself, having first written every one of them to disk",+        }),+    );+    let remaining: Vec<Value> = recovered+        .iter()+        .filter(|r| r.get("preserved").and_then(|v| v.as_bool()) == Some(false))+        .cloned()+        .collect();+    let out = json!({+        "recoveryDir": dir_s,+        "recovered": recovered,+        "closed": closed,+        "remaining": remaining,+        "closeFailed": close_failed,+        "documentCountBefore": before,+        "documentCountAfter": after,+        "done": done && stopped_by_dialog.is_none() && !dialog_unreadable+            && stopped_by_headroom.is_none(),+        // ALWAYS REPORTED, whether or not they stopped anything: a sweep that+        // writes every open document to disk and never says how much room it had+        // is asking to be trusted about the one thing it cannot verify afterwards.+        "headroomBytesBefore": free_before,+        "estimatedWriteBytes": estimate.total,+        "estimatedFrom": json!({+            "documents": open_paths.len(),+            "measured": estimate.measured,+            "estimated": estimate.estimated,+            "largestBytes": estimate.largest,+        }),+        "stoppedByDialog": stopped_by_dialog,+        // WHY it stopped, in one word a caller can branch on.+        "stoppedBy": if stopped_by_dialog.is_some() {+            json!("dialog")+        } else if stopped_by_headroom.is_some() {+            json!("insufficientHeadroom")+        } else if dialog_unreadable {+            json!("dialogCensusUnknown")+        } else {+            Value::Null+        },+        // Whether anybody could check for a dialog while this ran. The sweep does+        // not depend on it; the report says so rather than implying a check that+        // did not happen.+        "dialogCensusReadable": !dialog_unreadable,+        "windowEtiquette": "background",+        "progress": job.finished(),+    });+    if let Some(d) = out.get("stoppedByDialog").cloned().filter(|v| !v.is_null()) {+        let mut e = err_code(+            "stopped_by_dialog",+            format!("SOLIDWORKS raised a dialog during the sweep: {d}"),+            "NOTHING was answered and the sweep stopped. A save prompt should be impossible here — \+             every document is written to disk before anything closes — so a dialog means a premise \+             is wrong, and this bridge never answers one. swx_state takes a background screenshot; \+             show the user and let them answer, then run this again.",+        );+        if let Value::Object(m) = &mut e {+            for (k, v) in out.as_object().expect("object") {+                m.entry(k.clone()).or_insert(v.clone());+            }+        }+        return e;+    }+    if let Some(h) = stopped_by_headroom.clone() {+        let free = h.get("freeBytes").and_then(|v| v.as_u64()).unwrap_or(0);+        let need = h.get("neededBytes").and_then(|v| v.as_u64()).unwrap_or(0);+        let mut e = err_code(+            "insufficient_headroom",+            format!(+                "the recovery volume has {free} free byte(s) and this sweep needs about {need} \+                 ({} estimated for the session, doubled) — it {}",+                estimate.total,+                if recovered.is_empty() {+                    "wrote NOTHING and closed NOTHING".to_string()+                } else {+                    format!(+                        "stopped after preserving {} document(s) and closed NOTHING further",+                        recovered.len()+                    )+                }+            ),+            "A recovery file can be the RIGHT document CUT SHORT, and nothing in the file would \+             show it: the container marker sits at offset 4, so any prefix carries it, and a \+             truncation past 16 KB clears the size floor too. This bridge therefore refuses to \+             write a session it cannot fit rather than close documents against files it could not \+             have checked. Free space on the recovery volume, or point `recoveryDir` at one with \+             room, and run this again — nothing was lost and nothing was closed.",+        );+        if let Value::Object(m) = &mut e {+            m.insert("stoppedByHeadroom".into(), h);+            for (k, v) in out.as_object().expect("object") {+                m.entry(k.clone()).or_insert(v.clone());+            }+        }+        return e;+    }+    if dialog_unreadable {+        let mut e = err_code(+            "dialog_census_unknown",+            format!(+                "the window census could not be read, so this bridge cannot say whether a dialog \+                 is on screen — it PRESERVED {} document(s) to the recovery folder and closed NONE \+                 of them",+                recovered.iter().filter(|r| r["preserved"] == true).count()+            ),+            "Saving loses nothing, so the saves went ahead; closing while blind does not, so no \+             document was closed. A dialog nobody can see is exactly the one that matters — this is \+             the rule swx_stop applies to the same question. The work is already safe in \+             `recoveryDir` (swx_recover_list finds it). swx_status reports whether ab is reachable; \+             run this again when it is.",+        );+        if let Value::Object(m) = &mut e {+            for (k, v) in out.as_object().expect("object") {+                m.entry(k.clone()).or_insert(v.clone());+            }+        }+        return e;+    }+    ok_merge(out, json!({+        "_hint": if done {+            "Every open document was SAVED to the recovery folder and then closed; the session is \+             empty and the count proves it. Nothing of the user's was written over: a document that \+             already had a file was saved INTO recovery — the open document then pointed there, \+             which is what made the close key right, and their original file on disk was never \+             written to. `recoveryBytes` on each record is the size actually written, so a real \+             save can be told from a file that merely appeared. swx_recover_list shows what was \+             preserved, and swx_open reopens any of it."+        } else {+            "Documents are still open. `remaining` names the ones that could NOT be written to disk \+             — those were deliberately not closed, because closing a document whose only copy is in \+             memory is the one thing this verb exists to prevent. Fix what the reason says and run \+             it again."+        },+    }))+}++/// `swx_shutdown` — preserve everything, close everything, quit. One verb,+/// because "close the documents and then quit" is one intention and splitting it+/// across two calls is how a session ends up half-closed.+///+/// IDEMPOTENT: with SOLIDWORKS not running there is nothing to do and it says so+/// rather than failing, so a caller can always finish with it.+pub fn shutdown(ctx: &Ctx, args: &Value) -> Value {+    gate!(ctx);+    if let Some(g) = require_reason(+        args,+        "swx_shutdown",+        "it saves every open document to a recovery folder, closes them, and quits SOLIDWORKS",+    ) {+        return g;+    }+    // NOTHING TO DO IS NOT A FAILURE.+    if let Ok(st) = ctx.call("status", json!({}), 20) {+        if st.get("running").and_then(|v| v.as_bool()) == Some(false) {+            return ok(json!({+                "alreadyStopped": true,+                "closed": 0,+                "recovered": [],+                "stopped": false,+                "_hint": "SOLIDWORKS is not running, so nothing was closed and nothing was quit.                           This verb is idempotent on purpose: it is the last thing a caller does,                           and the last thing should be safe to repeat.",+            }));+        }+    }+    let swept = close_all_safely(ctx, args);+    if swept.get("success").and_then(|v| v.as_bool()) != Some(true)+        || swept.get("done").and_then(|v| v.as_bool()) != Some(true)+    {+        // THE SWEEP DID NOT EMPTY THE SESSION, so the quit is not attempted: a+        // quit with documents open is the outcome swx_stop exists to refuse, and+        // reaching it through this verb would be a way round that rule.+        let mut out = swept;+        if let Value::Object(m) = &mut out {+            m.insert("stopped".into(), json!(false));+            m.insert(+                "quitSkipped".into(),+                json!("the session was not emptied, so SOLIDWORKS was not asked to quit"),+            );+        }+        return out;+    }+    let stop = ctx_stop(ctx, args);+    let mut out = swept;+    if let Value::Object(m) = &mut out {+        m.insert("stopped".into(), stop.get("stopped").cloned().unwrap_or(json!(false)));+        m.insert("quit".into(), stop);+        m.insert(+            "_hint".into(),+            json!(+                "Every open document was written to the recovery folder, then closed, then                  SOLIDWORKS was asked to quit — in that order, which is the whole safety property:                  nothing closes before it is on disk. `recovered` lists what was preserved and                  where; swx_recover_list finds it again later. On a Connected edition starting                  SOLIDWORKS again takes 1-3 minutes and may stop at a sign-in only the user can                  answer."+            ),+        );+    }+    out+}++/// The quit, under the standing authorization.+fn ctx_stop(ctx: &Ctx, args: &Value) -> Value {+    let mut a = args.clone();+    if let Value::Object(m) = &mut a {+        m.insert("userAuthorized".into(), json!(true));+        m.insert("confirm".into(), json!(true));+        m.insert("allowUserWrites".into(), json!(true));+        m.insert("force".into(), json!(true));+    }+    crate::verbs::dispatch(ctx, "swx_stop", &a)+}++/// ONE DOCUMENT A SWEEP CLOSED IN PLACE, as the listing reports it — or `None`+/// for a manifest entry that is not one.+///+/// A document that was clean and saved when a sweep reached it needed no copy:+/// its own file IS its preservation, so the sweep wrote nothing for it and closed+/// it at its own path — a file no sweep ever writes to. That leaves it with no+/// file in the recovery folder, which is exactly why it needs an entry here:+/// "where is my file?" must not come back empty for a document that is sitting,+/// untouched, at its own path. Measured live 2026-09-30: the record had nothing+/// to say about such a document at all.+///+/// "UNTOUCHED" IS MEASURED, NOT ASSUMED. The sweep recorded the file's size and+/// modification time when it closed the document; this compares them with what+/// is on disk now, and says which of the three it is — the same, changed since+/// (the user reopened and saved it, most likely), or gone from its path.+fn closed_in_place_entry(e: &Value) -> Option<Value> {+    let flagged = e.get("closedInPlace").and_then(|v| v.as_bool());+    let original = e.get("originalPath").and_then(|v| v.as_str()).unwrap_or("").to_string();+    if original.trim().is_empty() {+        return None;+    }+    // A RECORD FROM BEFORE 0.9.0 carries no flag, but the same facts identify+    // such a document: the sweep called it preserved, wrote no copy, and it had+    // a path of its own.+    let legacy_shape = flagged.is_none()+        && e.get("recoveryPath").map(|v| v.is_null()).unwrap_or(true)+        && e.get("preserved").and_then(|v| v.as_bool()) == Some(true);+    if !(flagged == Some(true) || legacy_shape) {+        return None;+    }+    let meta = std::fs::metadata(&original).ok().filter(|m| m.is_file());+    let present = meta.is_some();+    let bytes_now = meta.as_ref().map(|m| m.len());+    let modified_now = if present { crate::scratch::modified_ms(&original) } else { None };+    let bytes_then = e.get("originalBytes").and_then(|v| v.as_u64());+    let modified_then = e.get("originalModifiedMs").and_then(|v| v.as_u64());+    let differs = matches!((bytes_then, bytes_now), (Some(a), Some(b)) if a != b)+        || matches!((modified_then, modified_now), (Some(a), Some(b)) if a != b);+    let comparable = (bytes_then.is_some() && bytes_now.is_some())+        || (modified_then.is_some() && modified_now.is_some());+    // null when there is nothing to compare: no measurement then, or no file now.+    let untouched: Option<bool> = if !present {+        None+    } else if differs {+        Some(false)+    } else if comparable {+        Some(true)+    } else {+        None+    };+    // CLOSED, as the sweep recorded it. A pre-0.9.0 record computed `closed` from+    // the recovery path — which a document closed in place does not have — so it+    // always said false; that is not evidence, and it is reported as unknown.+    let closed = if flagged == Some(true) {+        e.get("closed").and_then(|v| v.as_bool())+    } else {+        None+    };+    let closed_words = match closed {+        Some(true) => format!("it was closed at its own path, {original}"),+        Some(false) => format!(+            "this record does NOT show it closed — it may still be open in SOLIDWORKS, at \+             its own path, {original}"+        ),+        None => format!(+            "its own path is {original} (this record was written by a bridge older than 0.9.0, \+             which did not record whether a document it closed in place was closed)"+        ),+    };+    let file_words = match (present, untouched) {+        (false, _) => format!(+            "NOTHING IS AT {original} NOW: something moved or deleted that file after the sweep. \+             The sweep never writes to a document's own file and, since the document was clean, \+             kept no copy of it — there is none in this folder."+        ),+        (true, Some(true)) => format!(+            "The file is exactly as the sweep found it ({} byte(s)): untouched. Open it from \+             there with swx_open.",+            bytes_now.unwrap_or(0)+        ),+        (true, Some(false)) => format!(+            "The file HAS CHANGED since the sweep ({}). The sweep never writes to a document's \+             own file, so something else saved it afterwards — most often SOLIDWORKS, after the \+             document was reopened. Open it from there with swx_open.",+            match (bytes_then, bytes_now) {+                (Some(a), Some(b)) if a != b => format!("{a} byte(s) then, {b} now"),+                _ => "its modification time is later than the one recorded".to_string(),+            }+        ),+        (true, None) => "The sweep did not measure the file when it closed the document, so this \+                         bridge cannot say whether it has changed since; the sweep itself never \+                         writes to a document's own file. Open it from there with swx_open."+            .to_string(),+    };+    let headline = match (present, untouched, closed) {+        (false, _, _) => "NOT AT ITS PATH NOW.",+        (true, Some(true), Some(true)) => "CLOSED IN PLACE, UNTOUCHED.",+        (true, Some(false), _) => "AT ITS OWN PATH, CHANGED SINCE.",+        _ => "AT ITS OWN PATH.",+    };+    Some(json!({+        "closedInPlace": true,+        "source": "manifest",+        "title": e.get("title").cloned().unwrap_or(Value::Null),+        "originalPath": original,+        "kind": e.get("kind").cloned().unwrap_or(Value::Null),+        // No copy exists, by design: the document's own file was its preservation.+        "recoveryPath": Value::Null,+        "closed": closed,+        "present": present,+        "originalBytes": bytes_then,+        "originalModifiedMs": modified_then,+        "bytesNow": bytes_now,+        "modifiedMsNow": modified_now,+        "untouched": untouched,+        "howToOpen": format!(+            "{headline} This document was clean and saved when the sweep reached it, so no copy \+             was needed: {closed_words}. {file_words}"+        ),+    }))+}++/// `swx_recover_list` — what was preserved, and where. Read-only.+pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {+    let _ = ctx;+    let root = crate::scratch::root().join("recovery");+    // THE LEGACY LOCATION TOO. Until 0.9.0 recovery folders were written into+    // ab's install cache, which an update re-extracts. Anything still there is+    // migrated on start — but a rescue written by an older bridge that has not+    // migrated yet, or one whose copy failed verification, exists ONLY there,+    // and a listing that did not look would tell the user their work was gone+    // while it was sitting one directory over.+    let legacy = crate::scratch::legacy_root()+        .map(|l| l.join("recovery"))+        .filter(|l| l != &root);+    let mut sweeps: Vec<Value> = Vec::new();+    let mut dirs: Vec<(std::path::PathBuf, &'static str)> = Vec::new();+    for (base, location) in [(Some(root.clone()), "data"), (legacy.clone(), "legacyInstallCache")] {+        let Some(base) = base else { continue };+        if let Ok(entries) = std::fs::read_dir(&base) {+            dirs.extend(+                entries.flatten().map(|e| e.path()).filter(|p| p.is_dir()).map(|p| (p, location)),+            );+        }+    }+    // Newest first, across both locations.+    dirs.sort_by(|a, b| b.0.file_name().cmp(&a.0.file_name()));+    {+        for (d, location) in dirs {+            let mut 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+            // GUESS. Both were known at save time. A manifest carries them; where+            // there is none, this says so rather than presenting a weaker check as+            // the same answer.+            let manifest: Option<Value> = std::fs::read_to_string(d.join(MANIFEST_NAME))+                .ok()+                .and_then(|t| serde_json::from_str::<Value>(&t).ok())+                // A SHAPE CHECK IS NOT A VERSION CHECK. `files` being an array+                // says nothing about what its entries mean; a future manifest+                // with different field semantics would be read as this one.+                .filter(|m| {+                    m.get("files").map(|f| f.is_array()).unwrap_or(false)+                        && m.get("manifestVersion").and_then(|v| v.as_u64()) == Some(1)+                });+            let entries: Vec<Value> = manifest+                .as_ref()+                .and_then(|m| m.get("files").and_then(|f| f.as_array()).cloned())+                .unwrap_or_default();+            if let Ok(fs) = std::fs::read_dir(&d) {+                for f in fs.flatten() {+                    let p = f.path();+                    if !p.is_file() {+                        continue;+                    }+                    let name = p+                        .file_name()+                        .map(|n| n.to_string_lossy().to_string())+                        .unwrap_or_default();+                    // The record is not one of the rescues.+                    if name == MANIFEST_NAME || name == "manifest.json.tmp" {+                        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+                    // is not hidden either. It only survives a later start when+                    // the original it was copying is gone from ab's install cache,+                    // and then these may be the only bytes of that file left.+                    if let Some(real) = name.strip_suffix(".migrating") {+                        let original = if location == "data" {+                            legacy+                                .as_ref()+                                .zip(d.file_name())+                                .map(|(l, sweep)| l.join(sweep).join(real))+                                .filter(|o| o.is_file())+                        } else {+                            None+                        };+                        files.push(json!({+                            "path": p.to_string_lossy(),+                            "name": name,+                            "bytes": bytes,+                            "source": "migration",+                            "partialCopy": true,+                            "copyOf": real,+                            "legacyPath": original.as_ref().map(|o| json!(o.to_string_lossy())).unwrap_or(Value::Null),+                            "verified": false,+                            "verifiedReason": "an interrupted migration's temporary file: incomplete by \+                                               definition",+                            "howToOpen": match &original {+                                Some(o) => format!(+                                    "INTERRUPTED COPY of {real}: these bytes are incomplete. The \+                                     original is still at {}, in ab's install cache, and the \+                                     bridge copies it again on its next start.",+                                    o.display()+                                ),+                                None => format!(+                                    "PARTIAL COPY of {real}: a migration out of ab's install cache \+                                     was interrupted while copying it, and the original is no longer \+                                     there. These bytes are INCOMPLETE — SOLIDWORKS is unlikely to \+                                     open them — and are kept only because they may be the only \+                                     bytes of that file left."+                                ),+                            },+                        }));+                        continue;+                    }+                    let entry = entries.iter().find(|e| {+                        e.get("name").and_then(|v| v.as_str()).map(|n| n == name).unwrap_or(false)+                    });+                    files.push(match entry {+                        // ── THE SWEEP DECIDED THIS, and it knew things this verb+                        //    cannot re-derive. Report what it decided.+                        Some(e) => {+                            // THE VERDICT IS ABOUT BYTES, NOT ABOUT A NAME.+                            //+                            // The manifest records the size the sweep verified,+                            // and this verb has just read the size on disk. If+                            // they differ, the file CHANGED after the sweep — a+                            // disk that filled, an anti-virus quarantine, a sync+                            // client rewriting it — and the sweep's verdict is+                            // about bytes that are no longer there. Reporting it+                            // anyway is the worst version of this defect:+                            // `verified:true` in a manifest means the DOCUMENT+                            // WAS CLOSED, so this file is the only copy of that+                            // work and the user would be told it is intact. A+                            // vanished file is already `present:false`; an+                            // emptied one is the same event, and takes the same+                            // answer the reverified path gives — UNKNOWN.+                            // AN ENTRY THAT CANNOT BE BOUND TO THESE BYTES IS+                            // UNKNOWN, not "unchanged". `_ => false` read a+                            // MISSING or null `recoveryBytes` as agreement, so a+                            // manifest verdict was handed to a 900-byte file it+                            // was never about — and `verified:true` in a manifest+                            // means the document was CLOSED, so that file is the+                            // only copy of the work. Unbindable takes the same+                            // answer the reverified path gives.+                            let recorded = e.get("recoveryBytes").and_then(|v| v.as_u64());+                            enum Binding {+                                Same,+                                Changed,+                                Unbindable,+                            }+                            let binding = match (recorded, bytes) {+                                (Some(was), Some(now)) if was == now => Binding::Same,+                                (Some(_), Some(_)) => Binding::Changed,+                                // Either the record does not say what it verified,+                                // or this file's size cannot be read now.+                                _ => Binding::Unbindable,+                            };+                            let changed = matches!(binding, Binding::Changed);+                            let verified = match binding {+                                Binding::Same => {+                                    Some(e.get("verified").and_then(|v| v.as_bool()).unwrap_or(false))+                                }+                                _ => None,+                            };+                            let reason = if matches!(binding, Binding::Unbindable) {+                                format!(+                                    "this record cannot be tied to these bytes: it {} and the file \+                                     {}. A verdict about bytes nobody can match to this file is \+                                     not a verdict about this file.",+                                    match recorded {+                                        Some(n) => format!("verified {n} byte(s)"),+                                        None => "does not say how many bytes it verified".into(),+                                    },+                                    match bytes {+                                        Some(n) => format!("holds {n}"),+                                        None => "cannot be measured now".into(),+                                    }+                                )+                            } else if changed {+                                format!(+                                    "the file changed since the sweep: it verified {} byte(s) and \+                                     holds {} now. Whatever the sweep decided was about bytes that \+                                     are no longer there, so this bridge cannot vouch for what is.",+                                    recorded.unwrap_or(0),+                                    bytes.unwrap_or(0)+                                )+                            } else {+                                e.get("verdictReason")+                                    .and_then(|v| v.as_str())+                                    .unwrap_or("")+                                    .to_string()+                            };+                            let fallback = e+                                .get("namingFallback")+                                .and_then(|v| v.as_str())+                                .map(|x| x.to_string());+                            let kind_known = e.get("kind").map(|k| !k.is_null()).unwrap_or(false);+                            json!({+                                "path": p.to_string_lossy(),+                                "name": name,+                                "bytes": bytes,+                                "source": "manifest",+                                "title": e.get("title").cloned().unwrap_or(Value::Null),+                                "originalPath": e.get("originalPath").cloned().unwrap_or(Value::Null),+                                "kind": e.get("kind").cloned().unwrap_or(Value::Null),+                                "kindKnown": kind_known,+                                // THE NAME WAS A GUESS, and no file name can say so.+                                "namingFallback": fallback,+                                "originalBytes": e.get("originalBytes").cloned().unwrap_or(Value::Null),+                                "signatureOk": e.get("signatureOk").cloned().unwrap_or(Value::Null),+                                "closed": e.get("closed").cloned().unwrap_or(Value::Null),+                                "verified": verified,+                                "recoveryBytes": recorded,+                                "changedSinceSweep": changed,+                                "verifiedReason": if reason.is_empty() { Value::Null } else { json!(reason) },+                                "howToOpen": match verified {+                                    Some(v) => how_to_open(v, kind_known, &reason, fallback.is_some()),+                                    // CHANGED UNDERNEATH THE RECORD. Not a+                                    // refusal by the sweep, and not a rescue this+                                    // bridge can stand behind.+                                    None => format!(+                                        "UNCONFIRMED. {reason} The document this came from was \+                                         closed against the file as it WAS, so treat these bytes \+                                         as the only copy and check them before relying on them: \+                                         try swx_open (renaming a `.sldrecover` file first), and \+                                         if SOLIDWORKS refuses it, the work is gone from here."+                                    ),+                                },+                            })+                        }+                        // ── NO RECORD FOR THIS FILE. Verify what can be verified+                        //    and be explicit that it is less.+                        None => {+                            let kind = crate::disk::DocKind::from_extension(&name);+                            let v = crate::comhost::host::recovery_file_verdict(+                                &p.to_string_lossy(),+                                kind,+                                None,+                            );+                            reverified_entry(&p, &name, bytes, kind, &v)+                        }+                    });+                }+            }+            // A FILE THE SWEEP WROTE THAT IS NO LONGER THERE. Somebody moved or+            // deleted it; saying nothing would let a rescue disappear silently.+            for e in &entries {+                let name = e.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();+                // Listed already — under its own name, or as the partial copy an+                // interrupted migration left, which explains itself.+                if name.is_empty()+                    || files.iter().any(|f| f["name"] == json!(name) || f["copyOf"] == json!(name))+                {+                    continue;+                }+                // NOT GONE — NOT COPIED YET. A sweep migrated from ab's install+                // cache carries its manifest into the data root even when one of+                // its files did not copy (the copy failed verification, or the+                // start that was copying it died). That file is still in the old+                // location, and calling it deleted would tell the user their work+                // was lost while it sits one directory over, waiting for the next+                // update to take it.+                let at_legacy = if location == "data" {+                    legacy+                        .as_ref()+                        .zip(d.file_name())+                        .map(|(l, sweep)| l.join(sweep).join(&name))+                        .filter(|p| p.is_file())+                } else {+                    None+                };+                if let Some(original) = at_legacy {+                    files.push(json!({+                        "path": Value::Null,+                        "name": name,+                        "bytes": Value::Null,+                        "source": "manifest",+                        "present": false,+                        "notCopiedYet": true,+                        "legacyPath": original.to_string_lossy(),+                        "title": e.get("title").cloned().unwrap_or(Value::Null),+                        "verified": Value::Null,+                        "verifiedReason": "this file has not been copied out of ab's install cache \+                                           yet — its copy failed verification or was interrupted — \+                                           so the only copy is at `legacyPath`",+                        "howToOpen": format!(+                            "NOT COPIED YET. The only copy of this file is still in ab's install \+                             cache, at {}, which the next update of the bridge DELETES. The bridge \+                             retries the copy on its next start; if this file matters, copy it out \+                             of that folder now.",+                            original.display()+                        ),+                    }));+                    continue;+                }+                files.push(json!({+                    "path": e.get("recoveryPath").cloned().unwrap_or(Value::Null),+                    "name": name,+                    "bytes": Value::Null,+                    "source": "manifest",+                    "present": false,+                    "title": e.get("title").cloned().unwrap_or(Value::Null),+                    "verified": false,+                    "verifiedReason": "the sweep wrote this file and it is no longer in the \+                                       recovery folder",+                    "howToOpen": "GONE. This bridge wrote this file during a sweep and it is not \+                                  there now — it was moved or deleted by something else. If the \+                                  document it came from was closed, these were its only bytes.",+                }));+            }+            files.sort_by_key(|f| f.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string());+            // DOCUMENTS CLOSED AT THEIR OWN PATHS: no file here, by design, and+            // listed anyway — see `closed_in_place_entry`.+            let in_place: Vec<Value> = entries.iter().filter_map(|e| closed_in_place_entry(e)).collect();+            let endangered = location == "legacyInstallCache";+            // ALREADY COPIED OUT? The migration leaves the originals where they+            // were, so after it runs the same sweep is listed twice — and the two+            // must not read the same. A legacy sweep whose every file has a+            // same-sized counterpart under the data root says where that copy is;+            // one that does not is the only copy there is, and says THAT.+            let copied_to: Option<std::path::PathBuf> = if endangered {+                d.file_name().map(|n| root.join(n)).filter(|twin| {+                    twin.is_dir()+                        && std::fs::read_dir(&d)+                            .map(|rd| {+                                rd.flatten().filter(|e| e.path().is_file()).all(|e| {+                                    let theirs = e.metadata().ok().map(|m| m.len());+                                    let ours = std::fs::metadata(twin.join(e.file_name()))+                                        .ok()+                                        .filter(|m| m.is_file())+                                        .map(|m| m.len());+                                    theirs.is_some() && theirs == ours+                                })+                            })+                            .unwrap_or(false)+                })+            } else {+                None+            };+            sweeps.push(json!({+                "sweep": d.file_name().map(|n| n.to_string_lossy().to_string()),+                "directory": d.to_string_lossy(),+                // WHERE IT LIVES, because one of the two places is deleted by the+                // next update.+                "location": location,+                "atRiskFromUpdate": endangered,+                "copiedTo": copied_to.as_ref().map(|c| json!(c.to_string_lossy())).unwrap_or(Value::Null),+                "warning": match (endangered, &copied_to) {+                    (false, _) => Value::Null,+                    (true, Some(c)) => json!(format!(+                        "This is the ORIGINAL of a rescue written by a bridge older than 0.9.0, \+                         left in ab's install cache — which the next update empties. Every file \+                         in it has a copy in the data root, at {}: use that one.",+                        c.display()+                    )),+                    (true, None) => json!(+                        "This rescue is in ab's INSTALL CACHE, which is re-extracted — and \+                         emptied — whenever the bridge updates, and there is NO copy of it in the \+                         data root. It was written by a bridge older than 0.9.0. Copy anything \+                         you need out of it now; the bridge retries the migration on its next \+                         start and never deletes the original."+                    ),+                },+                "files": files,+                "closedInPlace": in_place,+            }));+        }+    }+    ok(json!({+        "recoveryRoot": root.to_string_lossy(),+        "legacyRecoveryRoot": legacy.as_ref().map(|l| json!(l.to_string_lossy())).unwrap_or(Value::Null),+        "sweeps": sweeps,+        // THE PER-FILE INSTRUCTION IS THE ONE TO FOLLOW. A blanket "reopen with+        // swx_open" was wrong for every .sldrecover file in the list, so the+        // general hint now points at the specific one rather than competing with+        // it.+        // WHAT IS IN HERE IS NOT ALL RESCUED WORK, and the headline must not+        // say it is. A sweep that preserved NOTHING still leaves files behind.+        "_hint": "Newest sweep first. **Files marked `verified:false` are NOT complete documents** \+                  — the sweep refused to close against them and left the document OPEN, so that \+                  work is still in SOLIDWORKS rather than here; their bytes are kept only because \+                  they are the only ones there are. A file marked `verified:true` was written and \+                  checked before its document was closed. Follow each file's own `howToOpen` \+                  rather than assuming swx_open works: a verified file whose kind could not be \+                  established is named `.sldrecover`, and SOLIDWORKS will not open it until it is \+                  renamed. Nothing in here is deleted by this bridge except by swx_uninstall — a \+                  verified recovery file is the user's only copy of work they had not saved. A \+                  document that was clean and saved needed no copy, so it has no file here: it is \+                  listed under its sweep's `closedInPlace`, closed at its own path — a file no \+                  sweep writes to — with whether that file is still untouched.",+    }))+}+ /// `swx_close_all_documents` — destructive, so it is confirm-gated. pub fn close_all_documents(ctx: &Ctx, args: &Value) -> Value {     gate!(ctx);@@ -494,6 +1933,9 @@ pub fn close_all_documents(ctx: &Ctx, args: &Value) -> Value {                     "bridgeCreated": owned,                     "errorCode": e.code,                     "why": e.message,+                    "detail": e.detail,+                    // The structured COM failure, where the host had one.+                    "hostError": e.data.clone().unwrap_or(Value::Null),                 })),             }         }@@ -1009,8 +2451,9 @@ pub fn close_import_temps(ctx: &Ctx, args: &Value) -> Value {             format!(                 "{} document(s) this bridge created are still open and CANNOT BE NAMED: they have \                  no path, and another open document shares each one's title, so no SOLIDWORKS API \-                 call can say which is meant. Nothing was attempted on them. Ask the user to close \-                 them in SOLIDWORKS (File > Close), or close every document and start again. This \+                 call can say which is meant. Nothing was attempted on them. Run \+                 swx_close_all_safely, which preserves every document to a recovery folder and \+                 verifies it before closing — it needs no name, because it closes everything. This \                  cannot happen to documents created by 0.6.0 or later, which are given a file of \                  their own at birth — see `documentIdentity` on swx_import and swx_new_document.",                 unaddressable.len()@@ -1041,7 +2484,9 @@ pub fn close_import_temps(ctx: &Ctx, args: &Value) -> Value {                  directory is the whole proof that a document is transient rather than the user's, \                  and the close does not prompt. To drain them, find the directory in \                  `importTempsElsewhereSample` and restart the bridge with SWX_TEMP_ROOT set to the \-                 temp directory above the swx<pid> folder — or ask the user to close them in \+                 temp directory above the swx<pid> folder — or close them with \+                 swx_close_all_safely, which preserves every document to recovery first and never \+                 needs anybody to close anything by hand \                  SOLIDWORKS. swx_docs reports the count.",                 match &temp_roots {                     Value::Array(a) if !a.is_empty() => a@@ -1223,7 +2668,12 @@ pub fn reset_workspace(ctx: &Ctx, args: &Value) -> Value {                 ctx.forget_scratch(&title);                 closed.push(title);             }-            Err(e) => skipped.push(json!({"title": title, "why": e.message})),+            Err(e) => skipped.push(json!({+                "title": title,+                "why": e.message,+                "detail": e.detail,+                "hostError": e.data.clone().unwrap_or(Value::Null),+            })),         }     }     // NULL, NOT `[]`, when the read failed or came back truncated. An empty@@ -2112,7 +3562,8 @@ fn reconcile_after_timeout(                 "the call timed out and {n} new unsaved documents matching '{stem}' are open — \                  this bridge will NOT guess which (if any) is the one its call created, so it \                  claims none of them. They may be unaddressable (same title, no path); ask the \-                 user to close them in SOLIDWORKS."+                 them with swx_close_all_safely, which writes every document to a recovery \+                 folder and verifies it before closing. Never ask the user to close documents."             ),         })),     }@@ -2137,11 +3588,42 @@ pub fn import(ctx: &Ctx, args: &Value) -> Value {     let before_docs = ownership_context(ctx).1;     let before_titles: Vec<String> = before_docs.iter().map(doc_title).collect();     let before_count = Some(before_docs.len() as u64);-    match ctx.call("import", json!({"filePath": path}), 280) {+    let mut host_args = json!({"filePath": path});+    if args.get("dissolve").and_then(|v| v.as_bool()).unwrap_or(false) {+        host_args["dissolve"] = json!(true);+    }+    // THE MEASUREMENT SURFACE, forwarded verbatim. These exist so the live+    // application can be asked which invocation actually imports a STEP on this+    // edition — nothing here interprets them, and `auto` is the default.+    for k in ["route", "openOptions", "docType", "withImportData", "argString"] {+        if let Some(v) = args.get(k) {+            host_args[k] = v.clone();+        }+    }+    match ctx.call("import", host_args, 280) {         Err(e) if e.timed_out => {             let stem = crate::comhost::mock::base_name(&path);             let stem = stem.rsplit_once('.').map(|(a, _)| a).unwrap_or(&stem).to_string();             let mut out = sidecar_error(e);+            // WHY DID IT STOP ANSWERING? On this install `swImportAutoRunImport-+            // Diagnostics` is ON (measured 2026-09-17), so a STEP with faulty+            // faces raises a MODAL Import Diagnostics dialog mid-import and the+            // COM call never returns. That is a dialog, not a hang, and the two+            // call for opposite responses from a caller — so it is NAMED. It is+            // never answered: the choice it offers changes the geometry the user+            // gets.+            if let Some(Some(d)) = blocking_dialog_now(ctx) {+                if let Value::Object(m) = &mut out {+                    m.insert("stoppedByDialog".into(), d.clone());+                    m.insert("stoppedBy".into(), json!("dialog"));+                    m.insert(+                        "_hint".into(),+                        json!(format!(+                            "SOLIDWORKS is showing a dialog and the import is waiting behind it:                              {d}. This bridge NEVER answers one — Import Diagnostics in particular                              offers to heal faulty faces, which changes the geometry you get, and                              that is the user's decision. swx_state takes a background screenshot;                              show the user and let them answer, then run swx_docs to see what                              ended up open."+                        )),+                    );+                }+            }             if let Some(rec) = reconcile_after_timeout(ctx, &stem, &before_titles, before_count) {                 if let Value::Object(m) = &mut out {                     m.insert("reconciliation".into(), rec);@@ -2220,18 +3702,40 @@ pub fn import(ctx: &Ctx, args: &Value) -> Value {                     }                 }             }+            // A LINKED IMPORT IS A DIFFERENT THING AND MUST SAY SO FIRST. With+            // 3D Interconnect on — the user's setting, which this bridge reads+            // and never changes — the file was OPENED as a part holding a link+            // back to it, not translated into one. A caller told only "imported"+            // would be wrong about what moving or deleting the source file does.+            let linked = r.get("linked").and_then(|v| v.as_bool()).unwrap_or(false);+            let source = r.get("sourcePath").and_then(|v| v.as_str()).unwrap_or("the source file");+            let base = "It is a NEW open document, exactly like swx_new_document — it stays open \+                        and holds memory until you save it (swx_save_as) or close it \+                        (swx_close_doc). Importing repeatedly without closing is what leaks \+                        documents; swx_close_import_temps drains any left behind. \+                        `documentsOpened`/`netCountOk` report that exactly one document was \+                        added. swx_inspect_bodies tells you what actually arrived.";+            let hint = if linked {+                format!(+                    "OPENED AS A LINKED PART. This user's SOLIDWORKS has 3D Interconnect ON, so a \+                     neutral file is not translated — it is opened as a part holding a linked \+                     feature that points at {source}. The part is NOT self-contained: moving, \+                     editing or deleting the source file changes it. This bridge reads that \+                     preference and never changes it; `dissolve:true` is refused until the \+                     link-breaking API is measured on this install. {base}"+                )+            } else {+                format!(+                    "Imported by the translator (3D Interconnect is off), so the result is \+                     self-contained. {base} Which formats work depends on the install's \+                     translators — an unsupported one is a clear refusal, not a dialog."+                )+            };             ok_merge(r, json!({                 "documentIdentity": identity,                 "windowEtiquette": "background",                 "progress": job.finished(),-                "_hint": "Imported as a NEW open document, exactly like \-                          swx_new_document — it stays open and holds memory until you save it \-                          (swx_save_as) or close it (swx_close_doc). Importing repeatedly \-                          without closing is what leaks documents; swx_close_import_temps drains \-                          any left behind. `documentsOpened`/`netCountOk` report that exactly one \-                          document was added. Which formats work depends on the install's \-                          translators — an unsupported one is a clear refusal, not a dialog; \-                          swx_inspect_bodies tells you what actually arrived.",+                "_hint": hint,             }))         }     }
src/writes.rs+43
@@ -252,6 +252,49 @@ pub fn authorize(         return Ok(Grant { host_args: json!({}), user_write: false });     } +    // ── DREW'S STANDING AUTHORIZATION, 2026-09-11, and the ONE rule it changes.+    //+    //   "I despise every time you ask for help closing. You MUST find/build a way+    //    to safely close the docs/program."+    //+    // Ending the user's SOLIDWORKS session is `Kind::EndSession`, and until now+    // it needed the full opt-in chain INCLUDING the process-level environment+    // variable. That variable exists for a good reason — an AI must not be able+    // to grant itself write access to somebody's data — and it stays in force for+    // every other kind of write, unchanged.+    //+    // But for THIS act the human has already answered, standingly, and requiring+    // the env var would mean the bridge answering "ask the user to restart me+    // with a flag" — which is the exact experience the authorization exists to+    // end. So `userAuthorized:true` WITH A REASON is a grant for ending the+    // session and for nothing else.+    //+    // What makes that safe is not this check. It is that nothing is lost:+    // `swx_close_all_safely` writes every open document to the recovery folder+    // and verifies it on disk before anything closes, `swx_stop` still quits ONLY+    // a session with zero documents open, still refuses on a dialog, and still+    // never kills anything. The consent question moved; the data-safety+    // guarantees did not.+    if kind == Kind::EndSession && !foreign && !clobbers {+        let authorized = args.get("userAuthorized").and_then(|v| v.as_bool()).unwrap_or(false);+        let reason = args+            .get("reason")+            .and_then(|v| v.as_str())+            .map(|r| r.trim().len() >= 8)+            .unwrap_or(false);+        if authorized && reason {+            return Ok(Grant {+                host_args: json!({+                    "allowUserWrites": true,+                    "confirm": true,+                    "reason": args.get("reason").cloned().unwrap_or(Value::Null),+                    "userAuthorized": true,+                }),+                user_write: true,+            });+        }+    }+     let what = if kind == Kind::RunMacro {         "run a macro — arbitrary code — inside the user's SOLIDWORKS".to_string()     } else if kind.always_gated() {
tests/adversary21.rsadded+180
@@ -0,0 +1,180 @@+//! ADVERSARIAL REVIEW OF 0.7.0 — pass twenty-two, the data-loss surface.+//!+//! The gates around the sweep are sound. `done = count_after == Some(0) &&+//! failed.is_empty()`, so a document that could not be saved is never closed,+//! keeps the count above zero, and forces `done:false`; an UNREADABLE count is+//! not `Some(0)` either. `swx_shutdown` attempts the quit only on+//! `success && done`, and `swx_stop`'s own `check_preconditions` is a second,+//! independent gate that refuses on a truncated count and on any dialog. Two+//! gates, neither derived from the other — that is the right shape for this.+//!+//! The saving itself is where the risk is.++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()+}++/// THE RECOVERY WRITE IS VERIFIED BY EXISTENCE, NOT BY SIZE.+///+/// ```text+/// let on_disk = code == 0+///     && !now_path.trim().is_empty()+///     && std::path::Path::new(&now_path).exists();+/// ```+///+/// A file that exists and is EMPTY passes, the record says `preserved: true`,+/// and the document is then closed — discarding the only copy of the user's+/// unsaved work. A partial or zero-byte write is not a hypothetical here: the+/// machine this verb is meant to run on is down to single-digit gigabytes, and+/// SOLIDWORKS assemblies are large.+///+/// The idiom already exists in this file — `std::fs::metadata(out).map(|m| m.len())`+/// is computed for the export path — so the fix is to carry it here, refuse a+/// zero-length result, and REPORT the size so an operator can tell a real save+/// from an empty one.+///+/// I could not stage the partial write: the mock writes a fixed non-empty+/// string and its own check is `fs::write(..).is_err()`, which is STRONGER than+/// the real backend's. So this test asks for the reportable half — the size of+/// what was written — which is the minimum that makes the outcome auditable and+/// is a precondition for checking it at all.+#[test]+fn every_recovery_record_reports_the_size_it_wrote() {+    let b = Bridge::start_with(&[("SWX_ALLOW_USER_WRITES", "1")]);+    b.call("swx_start", json!({"reason": "preparing to close everything"}));+    let made = b.call("swx_new_document", json!({"type": "part", "keepUnsaved": true}));+    assert!(made["success"] == true, "precondition: a dirty pathless document exists: {}", s(&made));++    let r = b.call(+        "swx_close_all_safely",+        json!({"reason": "closing everything safely before a quit", "userAuthorized": true}),+    );+    let recs = r["recovered"]+        .as_array()+        .cloned()+        .or_else(|| r["records"].as_array().cloned())+        .unwrap_or_default();+    assert!(+        !recs.is_empty(),+        "precondition: the sweep preserved at least one document: {}",+        s(&r)+    );+    let preserved: Vec<&Value> = recs+        .iter()+        .filter(|x| x.get("preserved").and_then(|p| p.as_bool()) == Some(true))+        .collect();+    assert!(!preserved.is_empty(), "precondition: something was preserved: {}", s(&r));+    for rec in preserved {+        let has_size = rec.get("bytes").and_then(|v| v.as_u64()).is_some()+            || rec.get("recoveryBytes").and_then(|v| v.as_u64()).is_some()+            || rec.get("size").and_then(|v| v.as_u64()).is_some();+        assert!(+            has_size,+            "a record says preserved:true and does not say how many bytes reached disk. The \+             check behind it is `Path::exists()`, so a zero-byte file — an interrupted write on a \+             nearly-full disk — passes it, and the document is then CLOSED. Size is the only \+             thing that distinguishes a save from a file that merely appeared.\nrecord: {}\n{}",+            s(rec),+            s(&r)+        );+    }+}++/// THE USER'S OWN FILE IS NEVER TOUCHED — the invariant Drew actually stated.+///+/// A dirty document that already has a path is written into the recovery folder+/// under a new name; the file the user knows about must be byte-for-byte what it+/// was. This passes, and it is here because it is the one property that must+/// never regress on this surface.+///+/// Worth recording alongside it: `save_as3(doc, out, copy=false)` resolves to+/// `options = 1`, which is `swSaveAsOptions_Silent` ALONE — not Copy, which is+/// 2. The module header describes this call as "silent, copy-mode", and that is+/// wrong. The effect is a RE-POINT: the open document becomes the recovery file.+/// Nothing is overwritten, so the invariant holds — but the sweep then reads+/// `GetPathName` AFTER the save to get its close key, and that is correct only+/// BECAUSE it re-points. If someone later makes the flag match the comment and+/// passes Copy, `GetPathName` would still return the USER'S ORIGINAL path and+/// the close would be aimed at their file. The comment and the code must be made+/// to agree, in that direction.+#[test]+fn a_users_saved_file_is_byte_for_byte_unchanged_by_the_sweep() {+    let work = TempDir::new("adv21-userwork");+    let users_file = work.join("Bracket.SLDPRT");+    let original = b"the user's own work, saved last Tuesday";+    std::fs::write(&users_file, original).unwrap();+    let before = std::fs::read(&users_file).unwrap();++    let b = Bridge::start_with(&[("SWX_ALLOW_USER_WRITES", "1")]);+    b.call("swx_start", json!({"reason": "preparing"}));+    b.call(+        "swx_open",+        json!({"path": users_file.to_string_lossy(), "reason": "the user had this open"}),+    );+    b.call(+        "swx_close_all_safely",+        json!({"reason": "closing everything safely before a quit", "userAuthorized": true}),+    );++    let after = std::fs::read(&users_file).unwrap_or_default();+    assert_eq!(+        after, before,+        "the sweep modified a file the USER owns at {}. A dirty saved document must be written \+         into recovery under a new name; the file they know about is never the destination.",+        users_file.display()+    );+    assert!(+        !after.is_empty(),+        "the user's file at {} is now empty",+        users_file.display()+    );+}++/// "ANY DIALOG STOPS THE SWEEP" — BUT AN UNREADABLE CENSUS DOES NOT.+///+/// The sweep reports `dialogCensusReadable: false` and `stoppedByDialog: null`,+/// and proceeds to close every document anyway. So the rule is really "any+/// dialog I could SEE stops the sweep", and when the desktop cannot be+/// enumerated the sweep runs blind.+///+/// Everywhere else in this bridge an unreadable precondition refuses — the quit+/// ladder's P3 does exactly that ("the window census could not be taken... a+/// quit sent at a modal is a different act"). This is the same act, on the same+/// desktop, one verb earlier, and it is the verb that CLOSES things. A "Save+/// changes to Part1?" raised mid-sweep by SOLIDWORKS itself is precisely what+/// would be missed.+///+/// The recovery save runs first, so this is not by itself a loss of data — it is+/// the loss of the guard that was claimed. On the live session it also means the+/// sweep cannot notice the one dialog it most needs to.+#[test]+fn the_sweep_does_not_close_documents_with_the_dialog_census_unreadable() {+    let b = Bridge::start_with(&[("SWX_ALLOW_USER_WRITES", "1")]);+    b.call("swx_start", json!({"reason": "preparing"}));+    b.call("swx_new_document", json!({"type": "part", "keepUnsaved": true}));++    let r = b.call(+        "swx_close_all_safely",+        json!({"reason": "closing everything safely before a quit", "userAuthorized": true}),+    );+    let readable = r["dialogCensusReadable"].as_bool();+    if readable != Some(false) {+        return; // the census was readable; nothing to prove here.+    }+    assert_eq!(+        r["closed"].as_u64().unwrap_or(0),+        0,+        "the dialog census could not be read (dialogCensusReadable:false) and the sweep closed {} \+         document(s) anyway. The verb's own contract is that any dialog stops it; with the \+         desktop unreadable it cannot know whether one is up. The quit ladder refuses this exact \+         unknown one verb later — this is the same act on the same desktop, and it is the one \+         that does the closing.\n{}",+        r["closed"],+        s(&r)+    );+}
tests/adversary22.rsadded+57
@@ -0,0 +1,57 @@+//! ADVERSARIAL REVIEW OF 0.7.1 — pass twenty-three.+//!+//! Both of pass 22's fixes are genuinely wired, and I re-ran the mutations+//! rather than reading them:+//!+//!   * `MIN_RECOVERY_BYTES` -> 0  makes+//!     `a_zero_byte_recovery_file_does_not_authorise_a_close` FAIL;+//!   * `dialog_unreadable = false` makes+//!     `an_unreadable_dialog_census_preserves_but_closes_nothing` FAIL.+//!+//! The close key is right too: `now_path` is read from the SAME `doc` object the+//! walk saved, never from `ActiveDoc`, so the re-point cannot be read off a+//! different document.+//!+//! What is left is the value of the floor, and when the desktop is looked at.++use swx_bridge::comhost::host::MIN_RECOVERY_BYTES;++/// THE FLOOR IS 1 KB, AND THE COMMENT ABOVE IT SAYS WHY THAT IS TOO LOW.+///+/// From `com.rs`, immediately above the check:+///+/// > a SOLIDWORKS document is an OLE compound file and **the smallest real one+/// > is tens of kilobytes**, so "more than nothing, and at least a kilobyte"+/// > separates a written file from a created one+///+/// Those two halves disagree. If the smallest real document is tens of+/// kilobytes, a floor of one kilobyte does not separate a written file from a+/// truncated one — it separates it only from an EMPTY one. The likeliest+/// outcome of a partial write on a full disk, for a file whose real size is tens+/// to hundreds of kilobytes, is a truncation somewhere in between: 2 KB, 8 KB,+/// 30 KB. Every one of those passes, is recorded `preserved: true`, and the+/// document is then closed against it.+///+/// A byte floor is a guess about a format this bridge does not own. There is a+/// decisive check that costs eight bytes: SOLIDWORKS documents are OLE compound+/// files, so a real one begins with the CFBF signature+/// `D0 CF 11 E0 A1 B1 1A E1` at offset 0. Reading that is structural rather than+/// statistical, catches a truncation that happens to exceed any floor, and fails+/// CLOSED — if a future SOLIDWORKS changed container format the check would+/// refuse to close rather than close wrongly, which is the right direction for+/// this verb.+///+/// This test asserts only the cheap half: whatever floor is kept must be+/// consistent with the file sizes the code itself describes.+#[test]+fn the_recovery_floor_is_consistent_with_a_real_solidworks_document() {+    assert!(+        MIN_RECOVERY_BYTES >= 16 * 1024,+        "MIN_RECOVERY_BYTES is {MIN_RECOVERY_BYTES}. The comment above the check it guards says \+         the smallest real SOLIDWORKS document is TENS OF KILOBYTES, so a 1 KB floor admits every \+         truncation between 1 KB and the real size — which is the shape a partial write on a full \+         disk actually takes. Raise the floor to match the reasoning, or better, verify the OLE \+         compound-document signature D0 CF 11 E0 A1 B1 1A E1 at offset 0: eight bytes, structural \+         rather than statistical, and it fails closed."+    );+}
tests/adversary23.rsadded+93
@@ -0,0 +1,93 @@+//! ADVERSARIAL REVIEW OF 0.7.2 — pass twenty-four.+//!+//! MY PASS-23 RECOMMENDATION WAS WRONG. I proposed verifying the OLE compound+//! file signature `D0 CF 11 E0 A1 B1 1A E1` at offset 0 from general knowledge+//! of the format, without measuring a single file. I re-measured every+//! SOLIDWORKS document in this container and **0 of 166 carry it**. Shipped as+//! specified it would have made `recovery_file_verdict` refuse every file, so+//! every document would have been preserved and never closed — a silently inert+//! feature, and a mock written on the same premise would have agreed with it.+//! The lesson is the one this project keeps relearning: a check about somebody+//! else's format is a measurement, not a recollection.+//!+//! What I measured, independently, over all 166 `.SLDPRT`/`.SLDASM`/`.SLDDRW`+//! files in this container (three repos, 29 KB - 37 MB, parts, assemblies and+//! drawings):+//!+//! ```text+//! CFBF magic at offset 0          0 / 166+//! 00 00 00 04 at offset 4       166 / 166+//! bytes 0..4                      per-file, varying (no constant)+//! any other constant 4-byte+//!   window in the first 64 B      NONE — 34 to 112 distinct values at every+//!                                 offset from 8 to 60+//! constant tail (last 4 or 8 B)   NONE — per-file+//! head word echoed at the tail    NO (checked byte-order-reversed too)+//! ```+//!+//! So the marker 0.7.2 ships is the strongest one available from the evidence,+//! and there is no cheap structural integrity check in this format at all.++use swx_bridge::comhost::host::{MIN_RECOVERY_BYTES, SWDOC_FORMAT_MARKER};++/// The marker is what I measured, at the offset I measured it.+///+/// Pinned so it cannot drift back to something recalled rather than measured —+/// which is exactly how the CFBF proposal got as far as a recommendation.+#[test]+fn the_format_marker_is_the_one_measured_on_real_documents() {+    assert_eq!(+        SWDOC_FORMAT_MARKER,+        [0x00, 0x00, 0x00, 0x04],+        "the marker must stay the value measured on 166/166 real SOLIDWORKS documents at offset 4"+    );+    assert!(+        MIN_RECOVERY_BYTES >= 16 * 1024,+        "the floor must stay at or above 16 KB — the smallest real document measured here is \+         29,130 bytes"+    );+}++/// WHAT THE MARKER CANNOT DO, stated so nobody mistakes it for an integrity check.+///+/// The marker sits at offset 4, so **any prefix of a real file longer than eight+/// bytes carries it**. A truncated write that reaches 20 KB of a 500 KB assembly+/// passes the marker AND the 16 KB floor, is recorded `preserved: true`, and the+/// document is closed against it. Marker and floor together catch "empty" and+/// "not a SOLIDWORKS file"; neither catches "the right file, cut short", and the+/// measurements above say the format offers nothing cheap that would — no+/// constant tail, no length field, no head/tail relationship.+///+/// The remaining defence is therefore not in the file, it is in the decision to+/// start: this sweep writes every open document to disk, and it should refuse to+/// begin when the volume has less free space than the session it is about to+/// write. The container this is being developed in has been between 7 and 25 GB+/// free all series while holding a 37 MB assembly; a session with a large+/// assembly open is exactly where the truncation this cannot detect becomes+/// likely.+///+/// This test asserts the sweep reports the free space it had — the cheap half,+/// and the precondition for refusing on it.+#[test]+fn the_sweep_reports_the_headroom_it_had_before_writing() {+    use serde_json::json;++    let b = common::Bridge::start_with(&[("SWX_ALLOW_USER_WRITES", "1")]);+    b.call("swx_start", json!({"reason": "preparing"}));+    b.call("swx_new_document", json!({"type": "part", "keepUnsaved": true}));+    let r = b.call(+        "swx_close_all_safely",+        json!({"reason": "closing everything safely", "userAuthorized": true, "batch": 1}),+    );+    let text = serde_json::to_string(&r).unwrap().to_lowercase();+    assert!(+        text.contains("freebytes") || text.contains("freespace") || text.contains("headroom"),+        "the sweep writes every open document to disk and never says how much room it had. \+         Marker and floor cannot detect a truncation that keeps the header and exceeds 16 KB, so \+         the only defence against that class is refusing to start without headroom — and the \+         first step is reporting it.\n{}",+        serde_json::to_string_pretty(&r).unwrap()+    );+}++mod common;
tests/adversary24.rsadded+82
@@ -0,0 +1,82 @@+//! ADVERSARIAL REVIEW OF 0.7.3 — pass twenty-five.+//!+//! On the judgement call: **I agree with the Builder.** The distinction that+//! makes it principled is between a PREDICTION and a VERIFICATION. The headroom+//! gate predicts whether a write will fit; the marker, the floor, the+//! close-phase re-verify and the per-save census verify what actually reached+//! disk. Data safety rests entirely on the second group, and all of it still+//! runs when free space cannot be read. Refusing on an unmeasurable PREDICTION+//! would block a safe operation on a fact with no bearing on whether anything is+//! lost — and would put "ask the user" back in the path Drew ruled it out of.+//! Predictions may be unknown; verifications may not. `null` is the honest+//! report, and it is visible.+//!+//! That agreement is conditional, though, and the condition is this file: the+//! residual the call accepts is exactly the truncation class the verification+//! chain cannot catch (header intact, over the floor). How narrow that residual+//! is depends on the estimate being roughly right, and for a document whose size+//! cannot be measured it is not.+//!+//! MEASURED, over the 166 real documents in this container:+//!+//! ```text+//!          n     min        median      max+//! .SLDPRT  99    29,130     134,885     37,200,505+//! .SLDASM  38    95,492     4,139,430   16,644,232+//! .SLDDRW  29    319,803    1,958,169   32,250,713+//! ```++use swx_bridge::disk::estimate_writes;++/// A SMALL MEASURED DOCUMENT MAKES THE GUESS FOR AN UNKNOWN ONE SMALLER.+///+/// ```text+/// let unknown_cost = if biggest_known > 0 { biggest_known.saturating_mul(2) }+///                    else { UNKNOWN_DOCUMENT_ESTIMATE };+/// ```+///+/// With nothing measurable the estimator assumes 1 MB for an unknown document.+/// Measure one 29 KB part — the smallest real part in this container — and the+/// assumption for the unknown one DROPS to 58 KB. Knowing something small about+/// the session made the estimate of the unmeasured part of it worse.+///+/// It matters because `per_save_needed = estimate.largest * HEADROOM_FACTOR`,+/// so the same number gates the re-check before every save. A pathless assembly+/// beside a small part is costed at 58 KB; the median real assembly here is+/// 4.1 MB, and the largest is 16.6 MB. The per-save gate then passes with far+/// less room than the write needs, and the truncation that follows is the one+/// case marker-plus-floor cannot see.+///+/// The fix is one `max`: never let a measured neighbour lower the floor for an+/// unknown. Better still, key the floor to the document type the sweep already+/// knows — from the table above, a part floor of 256 KB, an assembly floor of+/// 8 MB and a drawing floor of 4 MB are each above the measured median. The same+/// table would strengthen `MIN_RECOVERY_BYTES`, which at 16 KB sits below the+/// smallest real document of EVERY type (29 KB, 95 KB, 320 KB).+#[test]+fn a_small_measured_document_never_lowers_the_guess_for_an_unknown_one() {+    let dir = std::env::temp_dir().join("adv24-estimate");+    let _ = std::fs::create_dir_all(&dir);+    let small = dir.join("small.SLDPRT");+    // The smallest real part measured in this container.+    std::fs::write(&small, vec![0u8; 29_130]).unwrap();++    // One unknown document, nothing measurable beside it.+    let alone = estimate_writes(&[String::new()]);+    // The same unknown document, beside one small measured one.+    let beside = estimate_writes(&[String::new(), small.to_string_lossy().to_string()]);++    let unknown_alone = alone.total;+    let unknown_beside = beside.total.saturating_sub(29_130);+    let _ = std::fs::remove_dir_all(&dir);++    assert!(+        unknown_beside >= unknown_alone,+        "the estimate for an UNKNOWN document fell from {unknown_alone} bytes to {unknown_beside} \+         bytes because a 29 KB part was measurable beside it. Knowing something small about the \+         session made the guess about the rest of it smaller. `estimate.largest` also gates the \+         per-save re-check, so this is the number that decides whether the next write is allowed \+         to start — and a pathless assembly costed at 58 KB against a 4.1 MB median is exactly \+         how a truncation the marker cannot detect gets through."+    );+}
tests/adversary25.rsadded+153
@@ -0,0 +1,153 @@+//! ADVERSARIAL REVIEW OF 0.7.4 — pass twenty-six.+//!+//! The estimator is fixed and the fix is sound: `neighbour_cost.max(floor)` is+//! monotone, and an unknown kind costs the generic 1 MB rather than the cheapest+//! kind. Pass 25's finding is closed.+//!+//! What is not closed is the CLASSIFIER underneath both numbers. `from_hint` is+//! total — it cannot say "I do not know", it says `Part`. Part carries the+//! LOWEST floor of the three on both sides. So every failure of the hint chain+//! resolves to the most permissive answer, which is the wrong direction for a+//! verification and, it turns out, the wrong direction for the estimate too.++use swx_bridge::comhost::host::recovery_file_verdict;+use swx_bridge::disk::{DocKind, UNKNOWN_DOCUMENT_ESTIMATE};++// ── BUILDER NOTE, 0.7.5. Three call sites below were adjusted, because the fix+//    these tests ask for CHANGES THESE TWO SIGNATURES — that is the fix:+//+//      * `recovery_file_verdict` now takes the KIND and the original's size,+//        instead of reading the kind back off the file name it was given. The+//        name came FROM the kind, so it could never have checked it.+//      * `DocKind::from_hint` now returns `Option<DocKind>`, keyed on the+//        extension alone, because the classifier has to be able to say "I do+//        not know".+//+//    Every assertion is unchanged, and each one still fails without its fix+//    (mutation-verified). The first test passes `None` for the kind, which is+//    exactly its scenario: GetType was unreadable.++fn marked(path: &std::path::Path, bytes: usize) {+    let mut v = vec![0u8; bytes];+    v[4..8].copy_from_slice(&[0x00, 0x00, 0x00, 0x04]);+    std::fs::write(path, v).unwrap();+}++/// AN ASSEMBLY THE BACKEND CANNOT TYPE IS VERIFIED AS A PART.+///+/// The chain, all of it in production code:+///+///   com.rs:909   `let doc_type = doc.int("GetType", &[]).unwrap_or(1);`+///   com.rs:1037  `match doc_type { 2 => "SLDASM", 3 => "SLDDRW", _ => "SLDPRT" }`+///   host.rs:414  `let kind = DocKind::from_hint(path);`  // the path just chosen+///+/// `GetType` is a COM call on a document being closed under memory pressure,+/// which is the condition this whole sweep exists for. When it fails the+/// `unwrap_or(1)` says "part", the recovery file is named `.SLDPRT`, and+/// `recovery_file_verdict` reads the kind back off the name the same failure+/// produced. The verification is asking the question to the answer.+///+/// A 30 KB truncated assembly then clears the 24 KB part floor and the document+/// is closed. The smallest real assembly measured here is 95,492 bytes; 30 KB is+/// under a third of it. The 80 KB assembly floor that would have caught it is+/// never consulted, because nothing downstream knows it was an assembly.+///+/// This is the coordinator's hypothesis and it is worse than stated: it does not+/// need a title without an extension, only one unreadable `GetType`, and the+/// extension is not a fallback for the type — it is DERIVED from it, so the two+/// cannot disagree and the second is not a check on the first.+///+/// The fix is the one proposed: an unknown kind must take the HIGHEST recovery+/// floor. Note the direction argument in `DocKind::from_hint`'s doc comment is+/// inverted — "too high a floor refuses to close a real document" is true and is+/// the SAFE error. A too-high floor refuses and says so; a too-low floor closes+/// against a file nobody checked. For a verification, refusing is the direction+/// to fail in.+#[test]+fn an_assembly_whose_type_could_not_be_read_is_held_to_the_part_floor() {+    let dir = std::env::temp_dir().join("adv25-kind");+    let _ = std::fs::create_dir_all(&dir);+    // What `unique_recovery_path` produces for a pathless assembly titled+    // "Assem1" when `GetType` returned nothing: doc_type 1, extension SLDPRT.+    let f = dir.join("Assem1.SLDPRT");+    marked(&f, 30_000);+    // The kind is UNKNOWN — that is the whole scenario: `GetType` failed.+    let v = recovery_file_verdict(&f.to_string_lossy(), None, None);+    let _ = std::fs::remove_dir_all(&dir);++    assert!(+        !v.ok,+        "a 30,000-byte recovery file was accepted as complete. It is an ASSEMBLY: the smallest \+         real one measured in this container is 95,492 bytes, so this is under a third of a \+         genuine document. It passed because one unreadable GetType chose the .SLDPRT name and \+         the verdict read the kind back off that name. Unknown kind must take the HIGHEST floor \+         on the verification side — refusing to close is the safe error; closing against an \+         unchecked file is not."+    );+}++/// THE HINT IS MATCHED AGAINST THE WHOLE PATH, DIRECTORIES INCLUDED.+///+/// `from_hint` is `h.contains("assembly")` / `h.contains("drawing")` over the+/// entire recovery path. `C:\Users\drew\Documents\Drawings\recovery\Part1.SLDPRT`+/// lowercases to something containing "drawing", so a genuine part is held to the+/// 256 KB DRAWING floor and refused — at 134,885 bytes, the MEDIAN real part+/// here does not clear it.+///+/// This one costs availability rather than work, and it is loud, but it fires on+/// a directory name a person would actually choose for exactly this purpose, and+/// on the first live close. Match the extension, not the string.+#[test]+fn a_recovery_directory_named_for_drawings_does_not_reclassify_a_part() {+    let dir = std::env::temp_dir().join("adv25-Drawings").join("recovery");+    let _ = std::fs::create_dir_all(&dir);+    let f = dir.join("Part1.SLDPRT");+    // The MEDIAN real part measured in this container.+    marked(&f, 134_885);+    // Classified the way the sweep classifies a path — which is the thing under+    // test: the DIRECTORY must not outrank the extension.+    let v = recovery_file_verdict(&f.to_string_lossy(), DocKind::from_hint(&f.to_string_lossy()), None);+    let why = v.why.clone().unwrap_or_default();+    let _ = std::fs::remove_dir_all(std::env::temp_dir().join("adv25-Drawings"));++    assert!(+        v.ok,+        "a genuine 134,885-byte part — the median of the 99 real parts here — was refused \+         because its recovery DIRECTORY was called \"Drawings\". from_hint substring-matches the \+         whole path, so the folder name outranks the extension. Reason given: {why}"+    );+}++/// THE GENERIC UNKNOWN ESTIMATE IS UNREACHABLE FROM THE SWEEP.+///+/// `estimate_writes_typed` costs an unknown-kind document at 1 MB, and the sweep+/// passes `None` only when `type`, `docType` AND `title` are all empty+/// (verbs3.rs:533). Every SOLIDWORKS document has a title. So the sweep always+/// supplies a hint, `from_hint` never fails, and UNKNOWN_DOCUMENT_ESTIMATE is+/// dead on the real backend path — the fifth guard in this review to ship inert.+///+/// It is not merely unused, it is a regression: a pathless assembly titled+/// "Assem1" resolves to `Part`, costing 256 KB where the generic unknown would+/// have said 1 MB and the true assembly floor 8 MB. The title fallback made the+/// estimate FOUR TIMES WORSE than having no hint at all, and 32x under the+/// assembly floor it was meant to reach. "Assem1" is the default title+/// SOLIDWORKS gives a new assembly, so this is the common case, not a corner.+///+/// Both sides need the same change: a fallible `from_hint` returning+/// `Option<DocKind>`, `None` when nothing is recognisable — then the estimate+/// takes the generic 1 MB and the recovery floor takes the highest.+#[test]+fn a_pathless_assembly_titled_assem1_is_not_costed_as_a_part() {+    // A TITLE is read as a title. An unrecognisable one is None, and None costs+    // the generic unknown — never the cheapest kind.+    let kind = DocKind::from_title("Assem1");+    let cost = kind.map(|k| k.estimate_floor()).unwrap_or(UNKNOWN_DOCUMENT_ESTIMATE);+    assert!(+        cost >= UNKNOWN_DOCUMENT_ESTIMATE,+        "the default title of a new SOLIDWORKS assembly, \"Assem1\", classifies as {kind:?} and \+         is costed at {cost} bytes. With NO hint at all it would have been costed at the generic \+         {UNKNOWN_DOCUMENT_ESTIMATE}. The title fallback made the estimate worse than the absence \+         of one, and 32x under the 8 MB assembly floor. UNKNOWN_DOCUMENT_ESTIMATE is unreachable \+         from the sweep because from_hint cannot return None."+    );+}
tests/adversary26.rsadded+137
@@ -0,0 +1,137 @@+//! ADVERSARIAL REVIEW OF 0.7.5 — pass twenty-seven.+//!+//! The classifier change is right and the pass-26 findings are closed: the kind+//! is read from `GetType`, carried as a type NAME through `ready.docType`, and+//! re-parsed with `from_type_name` — never re-derived from the file name the+//! sweep itself chose. `from_extension` looks only at the last path segment, so+//! the `…\Drawings\` misclassification is gone. The `≥ original size` rule is+//! correctly scoped to `kind == None` only (`host.rs:431`), so a part that+//! legitimately shrinks — a suppressed feature, a deleted body, a lightweight+//! save — is still held to its own 24 KB floor and not to its old size. That+//! second half of the coordinator's question needed no change and got none.+//!+//! What is left is not the classifier. It is the two places the UNKNOWN branch+//! touches SOLIDWORKS, neither of which this suite can reach.++mod common;+use common::*;+use serde_json::json;+use swx_bridge::disk::DocKind;++fn s(v: &serde_json::Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++/// THE DOCUMENT'S OWN FILE PATH IS NOT CONSULTED.+///+/// The naming chain, verbatim from `com.rs:1086`:+///+/// ```ignore+/// let ext = match kind.or_else(|| DocKind::from_extension(title)) { … };+/// ```+///+/// `kind` is `GetType`, `title` is `GetTitle`. **`path` — `GetPathName`, which+/// the same function read four lines earlier and already uses for+/// `original_bytes` — is never asked.** It is the strongest evidence in the+/// room: the user's own file, named by SOLIDWORKS, on disk.+///+/// It matters because `GetTitle` does not reliably carry the extension.+/// SOLIDWORKS returns the title as the shell shows it, so with "hide extensions+/// for known file types" on — the Windows DEFAULT — a saved assembly's title is+/// `bracket`, not `bracket.SLDASM`. Combine that with one unreadable `GetType`+/// and a document whose kind is sitting in plain text in `path` is classified+/// unknown, named `.sldrecover`, and held to the drawing floor plus its original+/// size.+///+/// For a saved document that last part is survivable — refusing is loud and the+/// user's file is still on disk. The reason to fix it anyway is that it is one+/// `.or_else` against evidence already in hand, and it narrows the `.sldrecover`+/// path to the only case that truly has no extension anywhere: a pathless+/// document. Which is the case the next test is about.+#[test]+fn a_saved_assembly_is_classified_from_its_path_when_the_type_cannot_be_read() {+    // GetType unreadable — the condition this sweep exists for.+    let kind: Option<DocKind> = None;+    // GetTitle with Explorer's default "hide extensions" setting.+    let title = "bracket";+    // GetPathName. Read four lines earlier in the same function, for size.+    let path = "C:\\work\\bracket.SLDASM";++    // The production expression, copied — as 0.7.6 fixed it. The path is asked+    // before the title, because the title is the weaker evidence of the two:+    // SOLIDWORKS reports it as the shell shows it, and Explorer hides known+    // extensions by default.+    let chosen = kind+        .or_else(|| DocKind::from_extension(path))+        .or_else(|| DocKind::from_extension(title));++    assert!(+        chosen.is_some(),+        "a saved assembly was classified UNKNOWN while its kind was written in its own path, \+         {path:?}. The naming chain asks GetType, then the TITLE's extension, and stops — it \+         never asks the path, though the same function reads it for originalBytes. GetTitle \+         carries no extension whenever Explorer hides known file types, which is the default."+    );+}++/// WHAT `.sldrecover` COSTS, AND WHO IS TOLD.+///+/// `unique_recovery_path` gives an unknown-kind document the extension+/// `sldrecover`, which is not a format SOLIDWORKS knows. Two consequences, and+/// the bridge is silent about both.+///+/// FIRST, AND I CANNOT TEST IT HERE: `SaveAs3` dispatches its output format on+/// the extension. This codebase already knows that — `blocked_by_extension`+/// exists precisely because `.wrl` makes SaveAs3 raise a VRML option sheet, so+/// the extension demonstrably selects a writer. What `SaveAs3` does with an+/// extension matching NO writer is not something I will assert from memory; I+/// got a format claim wrong in pass 23 by doing exactly that. But there are only+/// two possibilities and both need an answer before a live run:+///+///   * It FAILS. The record says `preserved: false`, the document stays open,+///     nothing is lost — and 0.7.5's entire unknown-kind path (the strictest+///     floor, the `≥ original size` rule, this extension) is unreachable in+///     production, because the file it verifies is never written. A guard that+///     cannot be reached is the sixth this review has found shipping inert.+///   * It SUCCEEDS by falling back to some default writer. Then the bytes on+///     disk may not be the document's native format at all, and the marker check+///     would be validating a file whose format nobody chose.+///+/// The mock cannot distinguish these: it writes the file itself. Only the live+/// install can answer it, with one scripted `SaveAs3` to a junk extension on a+/// scratch document — which is a write, so it is not mine to run.+///+/// SECOND, AND THIS IS WHAT THE TEST PINS: if such a file IS written, it is the+/// rescue of a document that was never saved — the only copy of that work — and+/// SOLIDWORKS will not open it, because the shell and the dispatcher both go by+/// extension. `swx_recover_list` is the one verb that shows these files, and its+/// hint says "Reopen any of them with swx_open." For every `.sldrecover` file+/// that instruction is wrong, and nothing anywhere tells the user to rename it.+/// The verb has the file names in hand and could say so per file.+#[test]+fn recover_list_says_how_to_open_a_file_solidworks_will_not_open() {+    let scratch = TempDir::new("adv26-recover");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    // A sweep that preserved one document whose kind could not be established.+    let root = scratch.join("recovery").join("2026-09-12T00-00-00");+    std::fs::create_dir_all(&root).unwrap();+    let mut body = vec![0u8; 400_000];+    body[4..8].copy_from_slice(&[0x00, 0x00, 0x00, 0x04]);+    std::fs::write(root.join("Assem1.sldrecover"), &body).unwrap();++    let r = b.call("swx_recover_list", json!({}));+    let text = s(&r).to_ascii_lowercase();+    let listed = text.contains("sldrecover");+    assert!(listed, "precondition: the unknown-kind rescue is listed at all: {}", s(&r));++    assert!(+        text.contains("rename") || text.contains("sldasm") || text.contains("extension"),+        "swx_recover_list shows a .sldrecover file and tells the user \"Reopen any of them with \+         swx_open\". SOLIDWORKS dispatches on extension, so that is the one instruction that \+         cannot work for this file — and it is the rescue of a document that was NEVER SAVED, so \+         it is the user's only copy. Nothing in the response mentions renaming it or what to \+         rename it to. Response: {}",+        s(&r)+    );+}
tests/adversary27.rsadded+133
@@ -0,0 +1,133 @@+//! ADVERSARIAL REVIEW OF 0.7.6 — pass twenty-eight.+//!+//! BOTH RUNTIME QUESTIONS COME OUT CLEAN, by reading the code rather than by+//! testing it, because the branch needs a live SOLIDWORKS to enter.+//!+//! (1) After a refused `.sldrecover` save, `out` is reassigned BEFORE the second+//!     `SaveAs3`, and `now_path = GetPathName` is read after the `match attempt`+//!     — so the close key comes off the object after the SECOND save, and+//!     `same_path(&now_path, &out)` compares it against the new name. A document+//!     that failed to re-point is caught by that comparison, not assumed. If the+//!     second save also fails, `code != 0` puts it on the `preserved: false`+//!     path. Nothing re-points to a name that was never written.+//!+//! (2) A `.sldrecover` save that SUCCEEDS while writing some other format is+//!     verified with `kind = None` against `recovery_file_verdict`, which reads+//!     the marker first. A non-native file carries neither `00 00 00 04` at+//!     offset 4 nor the CFBF signature, so `signature_ok` is false, `ok` is+//!     false, and the document is NOT closed. Fails closed, correctly, and the+//!     retry is deliberately not attempted for a save that returned success —+//!     retrying on a verdict rather than on a return code would write twice for+//!     every genuinely-full disk.+//!+//! Which leaves where those two answers PUT THE FILE. In both cases a file is+//! left in the sweep directory that the sweep itself declined to vouch for —+//! correctly, since deleting a user's only bytes is worse than keeping them. The+//! record says `preserved: false` and names the reason. And then+//! `swx_recover_list` reads that directory with `p.is_file()` and calls+//! everything in it a rescue.++mod common;+use common::*;+use serde_json::json;++fn s(v: &serde_json::Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++/// THE LISTING DOES NOT KNOW WHAT THE SWEEP REFUSED TO VOUCH FOR.+///+/// Three ways a file lands in a sweep directory without being preserved work:+///+///   * the first `SaveAs3` to `.sldrecover` is refused AFTER touching the file,+///     leaving a husk at a name `out` no longer points to — 0.7.6 added the+///     retry, and the husk is what the retry leaves behind;+///   * the save returns 0 but writes a non-native format, and the marker check+///     rejects it;+///   * the save is truncated by a full disk and the size floor rejects it.+///+/// In every one the document stays OPEN and the record says `preserved: false`.+/// `swx_recover_list` sees none of that. It lists the file, reports its size,+/// and hands the user `howToOpen` — for a `.sldrecover` name, an instruction to+/// rename it to `.SLDPRT` and open it, ending "The bytes are the document; only+/// the name is unknown." For these files the bytes are NOT the document, which+/// is precisely why they were not closed against.+///+/// The response's own `_hint` states it outright: "Every document+/// swx_close_all_safely or swx_shutdown closed was written here first." True in+/// one direction and read as the other. The verb that reports the rescue is the+/// one the user reads on the worst day, and it is the one that cannot tell a+/// rescue from a husk.+///+/// `recovery_file_verdict` already computes exactly this answer at save time and+/// the listing throws it away — and it is a pure function of the file, so+/// `recover_list` can simply re-run it per file. Report `verified: false` and+/// replace `howToOpen` with what the sweep actually said. Nothing needs deleting.+#[test]+fn recover_list_does_not_present_an_unverified_file_as_preserved_work() {+    let scratch = TempDir::new("adv27-husk");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let root = scratch.join("recovery").join("2026-09-12T00-00-00");+    std::fs::create_dir_all(&root).unwrap();+    // What a refused or wrong-format SaveAs3 leaves: bytes that are not a+    // SOLIDWORKS document. recovery_file_verdict rejects this on the marker, and+    // the sweep left the document open because of it.+    std::fs::write(root.join("Assem1.sldrecover"), vec![0x41u8; 900_000]).unwrap();++    let r = b.call("swx_recover_list", json!({}));+    let files = r["sweeps"][0]["files"].as_array().cloned().unwrap_or_default();+    assert_eq!(files.len(), 1, "precondition: the husk is in the listing: {}", s(&r));+    let f = &files[0];++    assert!(+        f.get("verified").and_then(|v| v.as_bool()) == Some(false)+            || f.get("howToOpen")+                .and_then(|v| v.as_str())+                .map(|h| h.to_ascii_lowercase().contains("not verif")+                    || h.to_ascii_lowercase().contains("could not be verified"))+                .unwrap_or(false),+        "a file the sweep REFUSED to vouch for — it holds no SOLIDWORKS marker, so the document \+         it came from is still open — is listed as a rescue, with its size and an instruction to \+         rename it to .SLDPRT and open it, ending \"The bytes are the document; only the name is \+         unknown.\" Here the bytes are not the document. recovery_file_verdict is a pure function \+         of the file and already knows this; recover_list can re-run it per file. Entry: {}",+        s(f)+    );+}++/// THE HEADLINE CLAIM IS THE UNCONDITIONAL ONE.+///+/// A sweep in which NOTHING was preserved still produces a populated listing+/// under the hint "Every document … closed was written here first". A user+/// reading the listing after a failed sweep — the situation the listing exists+/// for — is told their work is safe by the verb whose job is to say so, while+/// the documents are still open and the files are unusable.+///+/// This is the same defect as the test above, stated where the user meets it. I+/// am counting it once.+#[test]+fn a_sweep_that_preserved_nothing_does_not_report_rescued_work() {+    let scratch = TempDir::new("adv27-nothing");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let root = scratch.join("recovery").join("2026-09-12T00-00-00");+    std::fs::create_dir_all(&root).unwrap();+    // Two attempts, both refused: no marker on either.+    std::fs::write(root.join("Assem1.sldrecover"), vec![0x41u8; 900_000]).unwrap();+    std::fs::write(root.join("Part1.SLDPRT"), vec![0x42u8; 40_000]).unwrap();++    let r = b.call("swx_recover_list", json!({}));+    let text = s(&r).to_ascii_lowercase();+    let files = r["sweeps"][0]["files"].as_array().cloned().unwrap_or_default();+    assert_eq!(files.len(), 2, "precondition: both attempts are listed: {}", s(&r));++    assert!(+        text.contains("verif"),+        "nothing in this sweep was preserved — neither file carries a SOLIDWORKS marker, so both \+         documents are still open — and swx_recover_list reports two rescues under \"Every \+         document swx_close_all_safely or swx_shutdown closed was written here first\". The word \+         \"verified\" does not appear anywhere in the response. Response: {}",+        s(&r)+    );+}
tests/adversary28.rsadded+186
@@ -0,0 +1,186 @@+//! ADVERSARIAL REVIEW OF 0.7.7 — pass twenty-nine.+//!+//! The listing is right about the case it was built for: a husk with no marker+//! is now `verified:false`, is never called a rescue, and says the document was+//! left open. Pass 28 is closed.+//!+//! ON THE CIRCULARITY CLAIM — I agree with it for every file except the one+//! class 0.7.6 introduced, and that exception matters.+//!+//! ```ignore+//! // The KIND from the extension is legitimate here and is not the 0.7.5+//! // circularity: this verb did not choose the name, it is reading a directory.+//! ```+//!+//! Who READS the name is not what made 0.7.5 circular; where the name CAME FROM+//! is. For a `.SLDPRT` written because `GetType` said part, the extension+//! records an independent observation and reading it back is sound. But a+//! `.SLDPRT` written by `namingFallback: "unknownKindNamedAsPart"` carries the+//! opposite: the sweep wrote that extension **because it could not establish the+//! kind**. The save verified it with `kind = None` against+//! `unknown_kind_floor()` — 256 KB. The listing reads `.SLDPRT`, gets+//! `DocKind::Part`, and applies 24 KB. Ten times weaker, on precisely the files+//! whose kind is unknown, from a name the failure produced. That is the same+//! shape as 0.7.4, one hop further downstream.+//!+//! AND THE WEAKNESS IS NOT SYMMETRIC THE WAY THE COMMENT ARGUES:+//!+//! ```ignore+//! // That makes this check WEAKER than the one at save time and never stricter,+//! // so nothing that was truly preserved can be reported as unverified here.+//! ```+//!+//! True, and it is the harmless half. A weaker check cannot produce a false+//! NEGATIVE; it is exactly what produces a false POSITIVE — `verified:true` for+//! a file the sweep refused. 0.7.6 said nothing about these files. 0.7.7 asserts+//! something about them, and an assertion that is wrong is worse than silence on+//! the day it is read.++mod common;+use common::*;+use serde_json::json;++fn s(v: &serde_json::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 a_difficult_session(scratch: &TempDir) {+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 3, "active": "untitled:a",+            "docs": [+                {"docType": "part", "path": "C:\\Users\\drew\\work\\bracket.SLDPRT",+                 "title": "bracket.SLDPRT", "saved": true, "dirty": true},+                {"docType": "part", "path": "untitled:a", "title": "First.step.SLDPRT",+                 "saved": false, "dirty": true},+                {"docType": "part", "path": "untitled:b", "title": "First.step.SLDPRT",+                 "saved": false, "dirty": true},+                {"docType": "assembly", "path": "C:\\Users\\drew\\work\\gearbox.SLDASM",+                 "title": "gearbox.SLDASM", "saved": true, "dirty": false},+                {"docType": "part", "path": "C:\\Users\\drew\\work\\shaft.SLDPRT",+                 "title": "shaft.SLDPRT", "saved": true, "dirty": false,+                 "heldOpenByAssembly": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+}+++/// A FILE THE SWEEP REFUSED FOR SIZE IS REPORTED AS VERIFIED.+///+/// Reachable two ways, both through the unknown-kind branch:+///+///   * `≥ original`. An unknown-kind document whose own file was 4 MB is+///     rescued at 300 KB. Save time: floor = `max(256 KB, 4 MB)` = 4 MB →+///     REFUSED, `preserved:false`, document left OPEN. Listing: the original+///     size is not knowable from a directory, so the floor alone applies —+///     300 KB clears 256 KB — `verified:true`, and `howToOpen` says "The bytes+///     verify as a document; only the name is unknown."+///+///   * the naming fallback. An unknown-kind document saved as `.SLDPRT` at+///     30 KB. Save time: `kind = None` → 256 KB floor → REFUSED. Listing:+///     `.SLDPRT` → Part → 24 KB → `verified:true`, `howToOpen: "swx_open this+///     path."`+///+/// In both, the sweep's record says the document is still open and the listing+/// says its work is safely on disk. The user reading the listing is the user+/// whose sweep just failed.+///+/// The floor-only weakening cannot be patched by making the listing stricter —+/// it genuinely cannot know the original size, and for the naming fallback it+/// cannot know the extension was a guess. Both facts existed at save time and+/// were thrown away. The fix is the durable one: the sweep writes its record+/// beside the files it wrote — a `manifest.json` per recovery folder — and the+/// listing reports what the sweep decided, falling back to re-verification only+/// for a folder with no manifest. That also makes `namingFallback` visible to+/// the reader, which nothing downstream can currently see.+#[test]+fn a_file_the_sweep_refused_is_not_reported_as_verified() {+    let scratch = TempDir::new("adv28-refused");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let root = scratch.join("recovery").join("2026-09-12T00-00-00");+    std::fs::create_dir_all(&root).unwrap();+    let mut body = vec![0u8; 300_000];+    body[4..8].copy_from_slice(&[0x00, 0x00, 0x00, 0x04]);+    // Unknown kind, rescued at 300 KB from a document whose own file was 4 MB.+    // The sweep refused this: max(256 KB, 4 MB) = 4 MB. The document is OPEN.+    std::fs::write(root.join("Assem1.sldrecover"), &body).unwrap();++    let r = b.call("swx_recover_list", json!({}));+    let files = r["sweeps"][0]["files"].as_array().cloned().unwrap_or_default();+    assert_eq!(files.len(), 1, "precondition: the file is listed: {}", s(&r));+    let f = &files[0];+    assert!(+        f["signatureOk"] == json!(true),+        "precondition: this fails on SIZE, not on the marker: {}",+        s(f)+    );++    assert!(+        f["verified"] != json!(true),+        "the listing reports verified:true for a file the sweep REFUSED and whose document it \+         left OPEN. At save time the unknown-kind floor was max(256 KB, original) — 4 MB for this \+         document — and the listing applies 256 KB because a directory cannot tell it what the \+         original weighed. A weaker check cannot produce a false negative; this is the false \+         POSITIVE it produces instead, and it is the direction that costs the user. Entry: {}",+        s(f)+    );+}++/// THE SWEEP DOES NOT WRITE DOWN WHAT IT DECIDED.+///+/// Everything the listing has to guess at — the original document's size, the+/// kind `GetType` reported, whether the extension was a guess — was in hand at+/// save time and is discarded when the process ends. The recovery folder holds+/// files and nothing else, so the listing is left re-deriving a verdict from+/// strictly less evidence than the one it is trying to reproduce, and the gap+/// between them is exactly the two false positives above.+///+/// A `manifest.json` written by the sweep into its own folder closes both, needs+/// no new judgement, and survives the process that made it — which matters most+/// here, because a sweep that ran during a crash is the one whose files someone+/// reads later from a fresh bridge.+#[test]+fn a_sweep_records_its_verdicts_beside_the_files_it_wrote() {+    let scratch = TempDir::new("adv28-manifest");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("adv28-manifest-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let swept = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    assert!(swept["done"] == json!(true), "precondition: the sweep ran: {}", s(&swept));++    let listed = b.call("swx_recover_list", json!({}));+    let dir = listed["sweeps"][0]["directory"].as_str().unwrap_or_default().to_string();+    assert!(!dir.is_empty(), "precondition: a sweep folder exists: {}", s(&listed));++    let names: Vec<String> = std::fs::read_dir(&dir)+        .map(|e| {+            e.flatten().map(|f| f.file_name().to_string_lossy().to_string()).collect()+        })+        .unwrap_or_default();++    assert!(+        names.iter().any(|n| n.to_ascii_lowercase().contains("manifest")),+        "the sweep wrote {} file(s) into {dir} and no record of what it decided about any of \+         them. The original sizes, the kinds GetType reported and whether an extension was a \+         guess all existed at save time; the listing has to re-derive a verdict without them, \+         and the two facts it cannot recover are exactly where it reports verified:true for a \+         refused file. Files present: {names:?}",+        names.len()+    );+}
tests/adversary29.rsadded+178
@@ -0,0 +1,178 @@+//! ADVERSARIAL REVIEW OF 0.7.8 — pass thirty.+//!+//! THE MANIFEST IS SOUND ON EVERY POINT IT WAS BUILT FOR.+//!+//!   * written after EVERY save (verbs3.rs:826) and EVERY close (:895), so a+//!     bridge that dies mid-sweep leaves a record of everything decided up to+//!     that document;+//!   * atomic — `manifest.json.tmp` then `rename` — so a crash mid-write leaves+//!     the previous complete manifest, never a truncated one that the listing+//!     would believe;+//!   * a crash between `SaveAs3` returning and the rewrite leaves a file with no+//!     entry, and that falls to `reverified_entry`, which yields `verified: null`+//!     — UNKNOWN — for a file that merely passes marker and floor. It returns+//!     `false` only for the conclusive case. That is the right asymmetry and it+//!     is the one I asked for in pass 29;+//!   * a manifest whose `files` is not an array is rejected wholesale by the+//!     `.filter()`, so a partial hand-edit degrades to re-verification rather+//!     than being half-believed;+//!   * a file the manifest names and the folder no longer holds is reported+//!     `present:false`, `verified:false`.+//!+//! The JSON-lines protocol is also fine: `send` uses `serde_json::to_string`,+//! not `to_string_pretty`, so a `description` carrying quotes or newlines is+//! escaped into one line, and a BSTR is `from_utf16_lossy` before it is ever a+//! Rust string, so no invalid UTF-8 can reach the frame.+//!+//! What is left is one gap in each half, and they have the same shape: a fact+//! that was known is not carried to the place that needs it.++mod common;+use common::*;+use serde_json::json;++fn s(v: &serde_json::Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++/// SEVERITY 1 — THE VERDICT IS NOT BOUND TO THE BYTES IT WAS A VERDICT ABOUT.+///+/// Entries are matched to files by `name` and nothing else. The manifest records+/// `recoveryBytes` — what the file weighed when the sweep vouched for it — and+/// the listing reads `bytes` live from `metadata()`. **The two are never+/// compared.** So a file that changed after the sweep is reported with the+/// sweep's `verified:true` beside its new size, and `howToOpen` says to open it.+///+/// This is the one case where a wrong answer costs the user work, because+/// `verified:true` in a manifest means the document WAS CLOSED — these bytes are+/// the only copy. The realistic causes are not adversarial: a disk that filled+/// after the sweep, an antivirus quarantine that truncates in place, a sync+/// client mid-upload, a cleanup tool. The listing already handles the file being+/// GONE (`present:false`). A file that was emptied is the same event and is+/// reported as a rescue.+///+/// It is also the answer to "can `source:\"manifest\"` be spoofed": the honest+/// version is not an attacker with write access to the user's scratch — that+/// person has better options — it is that `source:"manifest"` reads as+/// provenance while nothing binds the record to the file. Comparing the two+/// numbers the manifest and the directory already hold closes both, with no new+/// data and no hash: if `bytes != recoveryBytes`, the sweep's verdict is about a+/// file that no longer exists, so report `verified: null` and say the file+/// changed. That is exactly the `reverified` rule 0.7.8 already established.+#[test]+fn a_manifest_verdict_does_not_survive_the_file_changing_underneath_it() {+    let scratch = TempDir::new("adv29-bind");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let root = scratch.join("recovery").join("20260917-000000");+    std::fs::create_dir_all(&root).unwrap();+    let mut body = vec![0u8; 2_000_000];+    body[4..8].copy_from_slice(&[0x00, 0x00, 0x00, 0x04]);+    std::fs::write(root.join("bracket.SLDPRT"), &body).unwrap();+    std::fs::write(+        root.join("manifest.json"),+        json!({+            "manifestVersion": 1,+            "sweep": "20260917-000000",+            "files": [{+                "name": "bracket.SLDPRT",+                "recoveryPath": root.join("bracket.SLDPRT").to_string_lossy(),+                "title": "bracket.SLDPRT",+                "kind": "part",+                // VOUCHED FOR AT TWO MEGABYTES, and the document was CLOSED+                // against it. These bytes are the only copy.+                "recoveryBytes": 2_000_000,+                "signatureOk": true,+                "verified": true,+                "preserved": true,+                "closed": true,+            }],+        })+        .to_string(),+    )+    .unwrap();++    // The sweep is over. The disk fills, or a scanner quarantines in place, and+    // the file is truncated. Marker intact, 900 bytes of a 2 MB document.+    std::fs::write(root.join("bracket.SLDPRT"), &body[..900]).unwrap();++    let r = b.call("swx_recover_list", json!({}));+    let f = r["sweeps"][0]["files"][0].clone();+    assert!(f["source"] == json!("manifest"), "precondition: read from the manifest: {}", s(&f));+    assert!(f["bytes"] == json!(900), "precondition: the live size is read: {}", s(&f));++    assert!(+        f["verified"] != json!(true),+        "the listing reports verified:true for a file that is 900 bytes, using a verdict the \+         sweep reached when it was 2,000,000 — a number the manifest itself records as \+         recoveryBytes, right next to the live size this verb just read. Nothing compares them. \+         verified:true in a manifest means the DOCUMENT WAS CLOSED, so this file is the only copy \+         of that work and the user is being told it is intact. The verb already reports a file \+         that vanished as present:false; a file that was emptied is the same event. Entry: {}",+        s(&f)+    );+}++/// SEVERITY 2 — THE SWEEP RECORDS THE PROSE AND DROPS THE STRUCTURE.+///+/// 0.7.8 exists so a caller can read the translator's own HRESULT instead of+/// guessing from a sentence, and `sidecar_error` carries `e.data` through as+/// `hostError`. But `swx_close_all_safely` does not go through `sidecar_error`+/// for a host failure. It builds its own entry, at verbs3.rs:807 and again at+/// :881:+///+/// ```ignore+/// Err(e) => {+///     close_failed.push(json!({"why": format!("the com host errored: {}", e.message)}));+///     break 'sweep;+/// }+/// ```+///+/// `e.data` is discarded at the construction site, so no structured detail can+/// reach the caller from this verb no matter what the host sends. The same holds+/// at verbs3.rs:1408, :1753 and :2143, which build `{"why": e.message}` for+/// per-document failures.+///+/// It matters here more than in `swx_import`, for two reasons. The sweep is the+/// verb that runs when SOLIDWORKS is already misbehaving, so it is the one most+/// likely to meet an HRESULT worth reading. And its records are now written into+/// `manifest.json` and kept — so a prose-only verdict is not merely harder to+/// diagnose in the moment, it is the permanent record of why a document was not+/// preserved.+///+/// The test asks only that the field EXIST on a host-failure entry. It cannot be+/// null-because-the-host-sent-nothing here; it is absent because nothing+/// constructs it.+#[test]+fn a_sweep_that_fails_on_the_host_carries_the_structured_reason() {+    let scratch = TempDir::new("adv29-hosterr");+    std::fs::write(scratch.join("mock-com-timeout"), "").unwrap();+    let machine = TempDir::new("adv29-hosterr-machine");+    std::fs::write(machine.join("windows.json"), "[]").unwrap();+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let failed = r["closeFailed"].as_array().cloned().unwrap_or_default();+    assert!(!failed.is_empty(), "precondition: the host failed the sweep: {}", s(&r));+    let e = &failed[0];+    assert!(+        e["why"].as_str().unwrap_or("").contains("com host errored"),+        "precondition: this is the host-failure entry: {}",+        s(e)+    );++    assert!(+        e.get("hostError").is_some(),+        "the sweep's host-failure entry carries only prose. verbs3.rs:807 and :881 build \+         {{\"why\": format!(\"the com host errored: {{}}\", e.message)}} and discard e.data, so \+         the structured COM detail 0.7.8 was built to surface cannot reach the caller from this \+         verb whatever the host sends. This is the verb that runs when SOLIDWORKS is already \+         misbehaving, and since 0.7.8 its records are persisted into manifest.json — so a \+         prose-only verdict is the permanent record of why a document was not preserved. \+         Entry: {}",+        s(e)+    );+}
tests/adversary30.rsadded+266
@@ -0,0 +1,266 @@+//! ADVERSARIAL REVIEW OF 0.8.0 — pass thirty-one.+//!+//! WHAT CHECKS OUT.+//!+//!   * The toggle is read INSIDE `cmd_import` (com.rs:3558), so it is read on+//!     EVERY import, not once per host life. No cache, nothing stale, and a user+//!     who changes the setting between two imports gets the route they asked for+//!     on the second one.+//!   * The toggle is never written — not set, not flipped-and-restored. Right,+//!     and for the stated reason.+//!   * `register_created` fires the moment `OpenDoc6` hands back a document+//!     (com.rs:3394), before the recount, so the pathless interconnect part is+//!     owned at birth exactly as the translator route learned to do.+//!   * `import_diagnostics` (abwin.rs:118) matches `"import diagnostic"` /+//!     `"import diagnos"`, which is specific enough that a title has to be about+//!     import diagnostics to reach it, and the classifier returns+//!     `(kind, blocking, never_answer)` with both flags TRUE — so a wedged+//!     import reports `stoppedByDialog` and no answer is ever sent. I could not+//!     construct a plausible non-diagnostics SOLIDWORKS window that collides.+//!+//! WHAT DOES NOT. Four findings; two provable here, two needing a simulator+//! fault that does not exist yet, flagged as such rather than asserted.++mod common;+use common::*;+use serde_json::json;++fn s(v: &serde_json::Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++fn a_step_file(scratch: &TempDir, name: &str) -> String {+    let p = scratch.join(name);+    std::fs::write(&p, b"ISO-10303-21;\nHEADER;\nENDSEC;\nEND-ISO-10303-21;\n").unwrap();+    p.to_string_lossy().to_string()+}++/// SEVERITY 1 — `dissolve:true` IS SILENTLY IGNORED ON THE TRANSLATOR ROUTE.+///+/// The refusal lives INSIDE `import_via_interconnect` (com.rs:3349), which is+/// entered only when `interconnect == Some(true)`. With the toggle OFF — or+/// UNREADABLE, see below — `cmd_import` goes straight to `LoadFile4` and the+/// `dissolve` argument is never looked at again. No refusal, no mention in the+/// reply. The simulator repeats the shape exactly (mock.rs:2542): the refusal is+/// `fault(...) || (interconnect && dissolve)`.+///+/// The defence would be that there is no link to dissolve on the translator+/// route, so ignoring it is harmless. That is the argument this codebase has+/// rejected every other time it came up. The caller asked for a specific,+/// irreversible-sounding operation and got back a success that says nothing+/// about it; `swx_import`'s own reply carries `interconnect`, `linked` and+/// `openOptionsMeasured` precisely so nothing has to be inferred. An argument+/// the bridge did not honour is a fact about what happened.+///+/// It is worse in combination with the next finding: an UNREADABLE toggle also+/// routes to the translator, so a caller asking for `dissolve:true` on Drew's+/// machine — where 3D Interconnect is ON and every import really does produce a+/// linked part — would get a success with an intact link and no indication that+/// the request was dropped.+///+/// Either refuse `dissolve:true` before the route is chosen, or echo+/// `dissolveRequested: true, dissolved: false` with the reason.+#[test]+fn dissolve_is_not_silently_dropped_when_the_translator_route_is_taken() {+    let scratch = TempDir::new("adv30-dissolve");+    std::fs::write(scratch.join("mock-3d-interconnect-off"), "").unwrap();+    let file = a_step_file(&scratch, "smoke.step");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let r = b.call("swx_import", json!({"filePath": file, "dissolve": true}));++    assert!(+        r["errorCode"] == json!("dissolve_not_measured")+            || r.get("dissolveRequested").is_some()+            || s(&r).to_ascii_lowercase().contains("dissolve"),+        "swx_import was asked for dissolve:true and returned without refusing it, without \+         honouring it, and without mentioning it. The refusal sits inside \+         import_via_interconnect (com.rs:3349), so it only fires when the toggle reads ON; the \+         translator route never looks at the argument. On this user's machine the toggle IS on, \+         so a caller who reaches the translator route — because the setting was OFF, or because \+         it could not be READ — is told the import succeeded while the link it asked to break \+         either never existed or is still there. Reply: {}",+        s(&r)+    );+}++/// SEVERITY 2 — AN UNREADABLE TOGGLE IS TREATED AS "OFF", NOT AS UNKNOWN.+///+/// ```ignore+/// if interconnect == Some(true) {+///     return self.import_via_interconnect(…);+/// }+/// // ── ROUTE TWO: THE TRANSLATOR.+/// ```+///+/// `None` and `Some(false)` take the same branch. `pref_toggle`'s doc comment+/// says "an unreadable preference is UNKNOWN, and the import reports it as such+/// rather than assuming the default" — and the REPORT is honest, `interconnect`+/// comes back `null`. The ROUTE is not: unknown silently becomes off.+///+/// On this install that is the wrong half of the coin. 3D Interconnect is ON, so+/// an unreadable toggle sends the import down `LoadFile4`, which on this machine+/// returns no document — and the failure is then reported as a translator+/// failure, pointing the next diagnosis at the translator when the cause was an+/// unreadable preference. For a release whose entire purpose is measurement,+/// that is the expensive direction to guess in.+///+/// This bridge's own doctrine says so in this very file: "a silent second+/// attempt would make the next live run ambiguous… a guess nobody can see is+/// worse than a failure somebody can read." An unreadable toggle should refuse+/// and name the preference, or try the interconnect route and say which it used+/// — not take the off-branch silently.+///+/// NOT PROVEN BY THIS TEST. There is no simulator fault for a preference+/// SOLIDWORKS will not answer, so the `None` branch cannot be reached from a+/// test at all — which is itself the finding's second half: the UNKNOWN case+/// 0.8.0 introduced has no coverage of any kind. This test pins the reachable+/// precondition, that the route is reported per import, so that 0.8.1's+/// measurement overrides have something to sit on.+#[test]+fn the_import_reply_says_which_route_it_took_and_why() {+    let scratch = TempDir::new("adv30-route");+    std::fs::write(scratch.join("mock-3d-interconnect-off"), "").unwrap();+    let file = a_step_file(&scratch, "smoke.step");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let r = b.call("swx_import", json!({"filePath": file}));+    assert!(r["success"] == json!(true), "precondition: the import ran: {}", s(&r));++    assert!(+        r.get("interconnectSettingReadable").is_some()+            || (r.get("interconnect").map(|v| !v.is_null()).unwrap_or(false)+                && r.get("routeReason").is_some()),+        "the reply reports `interconnect` but not whether that value was READ or merely absent. \+         null means \"SOLIDWORKS would not answer\" and false means \"the user turned it off\", \+         and they take the SAME route while meaning opposite things about how much to trust the \+         result. A caller cannot distinguish them, and on this install they have opposite \+         consequences. Reply: {}",+        s(&r)+    );+}++/// SEVERITY 2 — THE `OpenDoc6` THROW PATH NEVER RE-COUNTS.+///+/// The recount the Builder added lands on the returns-null path: `after` is+/// taken, `net` is computed, and the detail says the count went `before -> after`.+/// It does NOT land on the THROW path. `.map_err(…)?` at com.rs:3367-3387+/// short-circuits the whole function before `document_count` is ever called+/// again, so a `OpenDoc6` that raises after opening something leaves a document+/// that is unowned, uncounted and unmentioned — and `register_created` is two+/// lines below the `?`, so it does not fire either.+///+/// The translator route has an ActiveDoc recovery branch for exactly this shape.+/// The interconnect route has none, on either failure path: even where the+/// recount DOES happen, `net > 0` with `opened == None` returns an error and+/// takes no ownership of whatever appeared.+///+/// Live relevance: `OpenDoc6` currently comes back with `errors = 2097152`+/// (`swFileRequiresRepairError`) and no document. That is the returns-null+/// shape, so today the recount runs — but it runs and then discards what it+/// learned, which is the half worth fixing now.+///+/// NOT PROVEN BY THIS TEST: there is no simulator fault that makes `OpenDoc6`+/// throw. The test pins the reachable neighbour — that a failed interconnect+/// import reports what the document count did — so the recount cannot be+/// removed silently.+#[test]+fn a_failed_interconnect_import_reports_what_the_document_count_did() {+    let scratch = TempDir::new("adv30-recount");+    std::fs::write(scratch.join("mock-import-translator-under-interconnect"), "").unwrap();+    let file = a_step_file(&scratch, "smoke.step");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let r = b.call("swx_import", json!({"filePath": file}));+    assert!(r["success"] == json!(false), "precondition: the import failed: {}", s(&r));++    let h = &r["hostError"];+    assert!(+        h.get("documentsOpened").is_some() || h.get("documentCount").is_some(),+        "a failed import reports errors, warnings and the path, but not what the open-document \+         COUNT did — the one number that says whether a document was left behind. It is in the \+         prose detail on one path and nowhere on the other, and it is the fact ownership turns \+         on. hostError: {}",+        s(h)+    );+}++/// SEVERITY 1 — THE SIMULATOR'S INTERCONNECT IMPORT IS NOT PATHLESS, AND THE+/// PRODUCTION CODE SAYS THE REAL ONE IS.+///+/// com.rs:3391, in the words of the route itself:+///+/// ```ignore+/// // OWN IT THE MOMENT IT EXISTS … The document this produces is PATHLESS (its+/// // title comes from the source file), so without registration at birth the+/// // drain could not identify it as the bridge's own.+/// ```+///+/// The simulator gives it a path: two imports of `smoke.step` come back as+/// `…\Temp\swx4242\IC~~\smoke-1.SLDPRT` and `…smoke-2.SLDPRT`. So every test+/// covering the drain and the sweep over this route is exercising the+/// FIND-BY-TEMP-PATH branch, which is the one that cannot run on Drew's machine,+/// while the ownership branch the comment says is load-bearing — `register_created`+/// plus identity-at-birth — is never the thing under test.+///+/// It is the release's central risk and it is the one thing the suite cannot+/// see. The drain's behaviour here looks correct and is: it finds both+/// documents, refuses to close either because `CloseDoc` resolves by name and+/// two documents share `smoke.SLDPRT`, and reports `titleCollisions` with both+/// full paths so the user can act. But it got the title collision from the+/// paths. With no paths — the live shape — the same two documents are+/// indistinguishable by every key the drain has except the registration made at+/// birth, and nothing here proves that key works.+///+/// Make the simulator's interconnect route pathless, and let it be the ownership+/// registry that tells the two apart. If the drain still refuses, it refuses for+/// the reason it will refuse live.+///+/// (Minor, from the same probe: `titleCollisions` reports one collision TWICE,+/// once per participating document, with `paths` in a different order each time.+/// A caller counting entries double-counts.)+#[test]+fn the_interconnect_route_produces_a_pathless_document() {+    let scratch = TempDir::new("adv30-pathless");+    let file = a_step_file(&scratch, "smoke.step");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    // 0.8.2 stopped letting `auto` prefer this route — OpenDoc6 is a MEASURED+    // failure under 3D Interconnect — so it is asked for by name. The property+    // under test is unchanged: what this route opens has no path, so ownership+    // must come from the registry.+    let r = b.call("swx_import", json!({"filePath": file, "route": "opendoc6"}));+    assert!(+        r["via"].as_str().unwrap_or("").contains("Interconnect"),+        "precondition: the interconnect route was taken: {}",+        s(&r)+    );++    let path = r["imported"]["path"].as_str().unwrap_or("").to_string();+    // BUILDER NOTE, 0.8.3: the finding is right and is fixed at the layer it is+    // about — the HOST now hands back a pathless document on this route, so the+    // drain's find-by-temp-path branch is no longer what these tests exercise.+    // The reply still carries a path because the SERVER then gives the document+    // an identity at birth (a scratch file), which is production behaviour since+    // 0.6.0 and happens live too: a bridge-created document that cannot be named+    // cannot be addressed by any verb. So the assertion is what the finding+    // actually requires — NO `IC~~` conversion temp, i.e. ownership cannot have+    // come from the temp-path branch — plus the identity that replaces it.+    assert!(+        !path.contains("IC~~"),+        "the simulator's 3D Interconnect import came back with path {path:?}. The production \+         route says in its own comment that this document is PATHLESS and that registration at \+         birth is therefore what makes it drainable. With a temp path in the fixture, every \+         drain and sweep test over this route takes the find-by-temp-path branch — the one that \+         cannot run on the live machine — and the ownership branch that will run there is \+         untested. This is the release's central risk and the suite cannot currently see it."+    );+    // AND THE OWNERSHIP THAT MUST REPLACE IT.+    let identity = r["documentIdentity"].clone();+    assert!(+        identity["identity"] == json!("path") && identity["scratchFile"] == json!(true),+        "the pathless document was not given an identity at birth, so nothing but its title \+         names it — and two imports of one file share a title. identity: {}",+        s(&identity)+    );+}
tests/adversary31.rsadded+255
@@ -0,0 +1,255 @@+//! ADVERSARIAL REVIEW OF 0.8.2 — pass thirty-two.+//!+//! WHAT CHECKS OUT+//!+//!   * An unknown `route` is refused BEFORE any COM call — it is the first thing+//!     `cmd_import` looks at, ahead of every other override (mock.rs:2556).+//!   * The overrides do not bypass ownership: the refusal for `dissolve` is+//!     evaluated after route selection and keyed on the TOGGLE, not the route,+//!     so `route:"opendoc6"` cannot smuggle a dissolve past it.+//!   * `interconnect_blocks_import` is keyed on `route == "auto"`, so an+//!     explicit route still measures. That is the point of the overrides and it+//!     is right.+//!   * The `auto` refusal sits AFTER the `net > 0` ActiveDoc-recovery branch+//!     returns (com.rs:3920 vs :3964), so a future edition whose LoadFile4+//!     accepts the file returns through the recovery path and IS registered and+//!     claimed. The coordinator's (3) is sound.+//!   * S1's byte binding catches a file that GREW as well as one that shrank —+//!     `was != now`, not `now < was`.+//!   * The sweep-folder fix is real and it is the root cause of the 1-in-12+//!     flake I reported in pass 30: two sweeps a fraction of a second apart+//!     shared a folder, the second manifest overwrote the first, and a second+//!     sweep that preserved nothing DELETED it. Good find.+//!+//! WHAT DOES NOT.++mod common;+use common::*;+use serde_json::json;++fn s(v: &serde_json::Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++/// SEVERITY 1, CONSENT — THE REFUSAL TELLS ITS READER TO CHANGE THE USER'S+/// SETTING, IN THE IMPERATIVE, WITH NO INSTRUCTION TO ASK FIRST.+///+/// `interconnect_blocks_import` carries:+///+/// > "Turn it off in SOLIDWORKS: Tools > Options > System Options > Import >+/// > uncheck "Enable 3D Interconnect", then import again … THIS BRIDGE DOES NOT+/// > CHANGE USER SETTINGS, not even temporarily."+///+/// The second sentence is the bridge's promise about ITSELF. The first is an+/// instruction, and the thing that reads it is an AI agent with a desktop bridge+/// and the ability to drive the SOLIDWORKS UI. "Turn it off in Tools > Options"+/// followed by the exact click path is a runbook, not a caveat, and the disclaimer+/// does not tell the reader not to perform it — it says the BRIDGE will not.+///+/// 3D Interconnect is a persistent, global CAD preference that changes how every+/// future import behaves, including imports the user does by hand long after this+/// session. The standing authorization on this machine covers closing documents+/// and quitting SOLIDWORKS; it does not cover reconfiguring Options. The+/// difference between "ask the user to turn it off" and "turn it off" is the+/// whole of consent here, and it is one word.+///+/// The fix is that word: address the instruction to the user via the caller —+/// "ASK THE USER to turn it off …" — and keep the click path, which is genuinely+/// useful for the human who will do it.+#[test]+fn the_interconnect_refusal_does_not_instruct_its_reader_to_change_the_setting() {+    let scratch = TempDir::new("adv31-consent");+    std::fs::write(scratch.join("mock-interconnect-blocks-import"), "").unwrap();+    let file = scratch.join("smoke.step");+    std::fs::write(&file, b"ISO-10303-21;\nHEADER;\nENDSEC;\nEND-ISO-10303-21;\n").unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let r = b.call("swx_import", json!({"filePath": file.to_string_lossy()}));+    assert!(+        r["errorCode"] == json!("interconnect_blocks_import"),+        "precondition: the measured refusal: {}",+        s(&r)+    );++    // THE REFUSAL'S OWN WORDS ONLY. `sidecar_error` staples a generic `_hint`+    // onto every host failure and it happens to contain "(or ask the user to)"+    // — about dismissing a modal dialog, a different subject. Matching the whole+    // reply would pass on that, which is the wrong-reason failure this review+    // exists to catch.+    let own: String = [&r["error"], &r["detail"], &r["hostError"]]+        .iter()+        .map(|v| s(v))+        .collect::<Vec<_>>()+        .join(" ")+        .to_ascii_lowercase();+    assert!(+        own.contains("ask the user") || own.contains("ask drew") || own.contains("the user must"),+        "the refusal says \"Turn it off in SOLIDWORKS: Tools > Options > System Options > Import\" \+         — an imperative with the exact click path, read by an agent that can drive the \+         SOLIDWORKS UI. The disclaimer that follows is about what the BRIDGE does, not about what \+         its reader should do. 3D Interconnect is a persistent global preference affecting every \+         future import, including the user's own by hand; the standing authorization here covers \+         closing documents, not reconfiguring Options. One word fixes it: ASK the user to turn it \+         off. Reply: {}",+        s(&r)+    );+}++/// SEVERITY 1 — THE BYTE BINDING DISAPPEARS WHEN `recoveryBytes` IS ABSENT.+///+/// ```ignore+/// let changed = match (recorded, bytes) {+///     (Some(was), Some(now)) => was != now,+///     _ => false,+/// };+/// ```+///+/// `_ => false` means "no recorded size — nothing changed", so the manifest's+/// `verified:true` is taken at face value with nothing binding it to the bytes+/// on disk. That is the same trust S1 was added to remove, reachable through any+/// manifest that lacks the field: one written by a bridge that predates it, one+/// hand-edited, one truncated to null by a partial write, or a future+/// `manifestVersion` that renames it — and the listing never looks at+/// `manifestVersion` at all, only that `files` is an array.+///+/// The direction is decided elsewhere in this same release: an unknown must take+/// the strict answer. `reverified_entry` already returns `verified: null` for+/// exactly this situation — bytes that pass what can be checked, with facts the+/// verb cannot recover. A manifest entry with no recorded size is that situation+/// with extra steps, and should get the same `null`, not `true`.+#[test]+fn a_manifest_entry_without_a_recorded_size_is_not_taken_on_trust() {+    let scratch = TempDir::new("adv31-nobytes");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let root = scratch.join("recovery").join("20260917-000000");+    std::fs::create_dir_all(&root).unwrap();+    let mut body = vec![0u8; 900];+    body[4..8].copy_from_slice(&[0x00, 0x00, 0x00, 0x04]);+    // 900 bytes: far under any floor. Whatever the sweep saw, it is not this.+    std::fs::write(root.join("bracket.SLDPRT"), &body).unwrap();+    std::fs::write(+        root.join("manifest.json"),+        json!({+            "manifestVersion": 1,+            "files": [{+                "name": "bracket.SLDPRT",+                "title": "bracket.SLDPRT",+                "kind": "part",+                // NO recoveryBytes — the field a older writer never wrote.+                "verified": true,+                "preserved": true,+                "closed": true,+            }],+        })+        .to_string(),+    )+    .unwrap();++    let r = b.call("swx_recover_list", json!({}));+    let f = r["sweeps"][0]["files"][0].clone();+    assert!(f["source"] == json!("manifest"), "precondition: read from the manifest: {}", s(&f));++    assert!(+        f["verified"] != json!(true),+        "a manifest entry with NO recoveryBytes is trusted completely: verified:true for a \+         900-byte file, under every floor this bridge has, because `_ => false` reads a missing \+         size as \"unchanged\". verified:true means the document was CLOSED. The listing does not \+         check manifestVersion either, so any writer that omits or renames the field silently \+         removes the binding S1 added. reverified_entry already answers this exact question with \+         null; a manifest entry that cannot be bound deserves the same. Entry: {}",+        s(&f)+    );+}++/// SEVERITY 2 — `GATE PARTIAL` AND `GATE PASSED` ARE THE SAME EXIT CODE.+///+/// `tools/drain-gate.py` ends:+///+/// ```python+/// if FAILURES: … return 1+/// if not twin_open:+///     print("GATE PARTIAL — … The COLLISION half is UNTESTED this run …")+///     return 0+/// print("GATE PASSED — …")+/// return 0+/// ```+///+/// The gate proves two properties: that `CloseDoc(<full path>)` closes exactly+/// the document it was given, and that a same-titled document survives. When the+/// twin was never open the second is untested, the text says so clearly — and+/// the process exits 0, indistinguishable from the run that proved both. Any+/// caller reading `$?` instead of the prose treats a half-run gate as a pass,+/// which is precisely the vacuity the `--fixture new` redirect makes more+/// likely: that path exists because `interconnect_blocks_import` now blocks the+/// STEP fixture on this machine, so the fixture that cannot raise the collision+/// is becoming the usual one.+///+/// A distinct code — 3 — costs nothing and keeps the prose honest for the human+/// while making the gate honest for the script.+#[test]+fn the_drain_gate_does_not_exit_zero_on_an_untested_half() {+    let src = std::fs::read_to_string(+        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/drain-gate.py"),+    )+    .expect("tools/drain-gate.py");++    let partial = src.find("GATE PARTIAL").expect("the partial verdict exists");+    let after = &src[partial..];+    let ret = after.find("return").expect("it returns something");+    let line: String = after[ret..].chars().take(12).collect();++    assert!(+        !line.contains("return 0"),+        "GATE PARTIAL returns 0, the same code as GATE PASSED. The partial verdict says the \+         COLLISION half is untested — one of the two properties the gate exists to prove — and a \+         caller reading the exit status cannot tell it from a full pass. The --fixture new \+         redirect makes this the likely path now that interconnect_blocks_import blocks the STEP \+         fixture on this machine. Found: {line:?}"+    );+}++/// SEVERITY 2 — A CODED REFUSAL CARRIES A GENERIC HINT THAT CONTRADICTS IT.+///+/// The whole reply for `interconnect_blocks_import`:+///+/// > `_hint`: "The COM host reported an error. If SOLIDWORKS is showing a modal+/// > dialog, dismiss it (or ask the user to) and retry; swx_status shows whether+/// > SOLIDWORKS is running and attached at all."+/// > `detail`: "Turn it off in SOLIDWORKS: Tools > Options > … Import …"+///+/// There is no dialog. Nothing is hung. The import was refused by a setting that+/// was read successfully, and retrying changes nothing until the user acts. The+/// `_hint` `sidecar_error` staples onto every host failure sends the reader to+/// look for a modal that does not exist, and it is the field agents read first.+///+/// This is the defect 0.7.6 fixed in `recover_list` — a blanket instruction that+/// was wrong for the specific files in the list — in a second place. The fix is+/// the same one: when the host gave a `code`, the coded reason is the hint, and+/// the generic sentence should not compete with it.+#[test]+fn a_coded_refusal_is_not_given_a_hint_about_a_dialog_that_is_not_there() {+    let scratch = TempDir::new("adv31-hint");+    std::fs::write(scratch.join("mock-interconnect-blocks-import"), "").unwrap();+    let file = scratch.join("smoke.step");+    std::fs::write(&file, b"ISO-10303-21;\n").unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);++    let r = b.call("swx_import", json!({"filePath": file.to_string_lossy()}));+    assert!(+        r["errorCode"] == json!("interconnect_blocks_import"),+        "precondition: the measured refusal: {}",+        s(&r)+    );++    let hint = r["_hint"].as_str().unwrap_or("").to_ascii_lowercase();+    assert!(+        !hint.contains("modal dialog") && !hint.contains("dismiss it"),+        "a refusal whose cause is a USER SETTING, read successfully, is handed the generic \+         host-failure hint: \"If SOLIDWORKS is showing a modal dialog, dismiss it … and retry\". \+         There is no dialog, nothing is hung, and retrying changes nothing. `_hint` is the field \+         a caller reads first and it contradicts the `detail` directly below it. When the host \+         gave a code, the coded reason is the hint. Reply: {}",+        s(&r)+    );+}
tests/adversary32.rsadded+390
@@ -0,0 +1,390 @@+//! ADVERSARIAL REVIEW OF 0.8.4 — pass thirty-three.+//!+//! THE DIALOG TEXT CENSUS IS STRICTLY READ-ONLY, and better than asked for.+//! `dialog_contents` (launchos.rs:1462) is `EnumChildWindows` + `GetClassNameW`+//! + `GetWindowTextW`. There is no UIA anywhere in it, so no `InvokePattern`, no+//! `SetFocus`, no `WM_CLOSE`, and no pattern object that could have a side+//! effect. `GetWindowTextW` against a control in ANOTHER process does not send+//! `WM_GETTEXT` — Windows returns the cached text instead — so not even a+//! message crosses the boundary. It takes `ENUM_LOCK`, clears its own static,+//! and reads. Nothing can be pressed by reading.+//!+//! Truncation is safe: `[u16; 1024]`, sliced by the returned length, through+//! `from_utf16_lossy`, so a cut mid-surrogate yields a replacement character+//! rather than a panic. It is SILENT, though — `DialogContents` has no+//! "truncated" flag, so a long message is reported as if it were the whole+//! message.+//!+//! `acknowledge_only` errs safe on the checkbox case. A Win32 checkbox's class+//! IS "Button", so "Don't show this again" lands in `buttons`, making `named.len()`+//! 2 and the verdict FALSE for a dialog that really is OK-only. It under-claims,+//! which is the right direction. The cost is that the `buttons` array a HUMAN+//! reads to decide what to click lists a checkbox as a button, and the simulator+//! hands back pre-classified lists, so this classification has no coverage at+//! all — the same fixture-fidelity gap as the interconnect route in pass 31.+//!+//! On the quit gate: `identity_mismatch` IS handled and refuses (quit-gate.py:468).++mod common;+use common::*;+use serde_json::json;+use std::path::Path;++fn s(v: &serde_json::Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++/// SEVERITY 1, CONSENT — THE "NEVER ASK" PIN DOES NOT READ THE FILE AN AGENT+/// READS, AND EXCUSES ANY LINE MENTIONING A DIALOG.+///+/// `nothing_asks_the_user_to_close_documents` walks `src/`, `tools/` and+/// `docs/`. `SKILL.md` and `README.md` live at the REPO ROOT and are not+/// scanned. `SKILL.md` is the file an agent loads to learn how to drive this+/// bridge: it is the highest-leverage place in the tree for "ask the user to+/// close" to reappear, and it is the one place the pin cannot see. Both are+/// currently clean — every hit in them states the rule rather than breaking it —+/// so this is a coverage hole, not a live violation, which is exactly when it is+/// cheap to close.+///+/// The second half is worse because it is load-bearing:+///+/// ```ignore+/// let is_a_dialog = l.contains("dialog") || l.contains("answer");+/// if asks && !is_the_rule && !is_a_dialog { … }+/// ```+///+/// Any line containing the word "dialog" is exempt. So+///+///   "a dialog is up, so ask the user to close the documents by hand"+///+/// passes the pin — and the dialog-blocked path is precisely where asking is+/// tempting, because it is the one branch where the bridge genuinely cannot+/// proceed. Drew's session is sitting behind a License Manager dialog right now.+/// The exemption should be for asking them to ANSWER a dialog, not for any+/// sentence that happens to name one.+///+/// (It is also line-granular, while nearly every hint in this codebase is a+/// `\`-continued string spanning several lines. A sentence broken across two+/// lines is invisible to it.)+#[test]+fn the_never_ask_pin_covers_the_files_an_agent_reads() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let pin = std::fs::read_to_string(root.join("tests/wiring.rs")).expect("wiring.rs");+    let body = pin+        .split("fn nothing_asks_the_user_to_close_documents")+        .nth(1)+        .expect("the pin exists")+        .to_string();++    // The files an agent actually loads to learn this bridge.+    assert!(+        root.join("SKILL.md").exists() && root.join("README.md").exists(),+        "precondition: SKILL.md and README.md are at the repo root"+    );+    let scans_root_docs = body.contains("SKILL.md")+        || body.contains("root.join(\"README.md\")")+        || body.contains("read_dir(root)")+        || body.contains("stack = vec![root.to_path_buf()");++    // THE EXEMPTION, TESTED AS A PROPERTY — and by 0.8.6 there is no exemption+    // block left to read. Pass 35 replaced the phrase exemptions with ONE+    // positional rule (`asks_to_close`: the close must FOLLOW the ask, inside the+    // same request) pinned by a table of example sentences. So this check is now+    // the strongest available: does that table carry THIS finding's own sentence+    // as a must-CATCH case?+    //+    // "A dialog is up, so ask the user to close the documents by hand." is the+    // sentence this test was written about. Present in the table expecting+    // `true`, the word-based excuse is provably gone — a shape check could only+    // ever have suggested that.+    let planted = "ask the user to close the documents by hand";+    let word_based_dialog_excuse = !pin+        .split(planted)+        .nth(1)+        .map(|after| after.chars().take(24).collect::<String>().contains("true"))+        .unwrap_or(false);++    assert!(+        scans_root_docs && !word_based_dialog_excuse,+        "the pin walks src/, tools/ and docs/ only, so SKILL.md and README.md — the files an \+         AGENT reads to learn how to drive this bridge — are outside it; and it exempts ANY line \+         containing the word \"dialog\", so \"a dialog is up, so ask the user to close the \+         documents by hand\" passes. That is the one branch where asking is tempting, and it is \+         where Drew's session is sitting right now. Exempt asking them to ANSWER a dialog, not \+         every sentence that names one. (scans root docs: {scans_root_docs}, word-based dialog \+         excuse: {word_based_dialog_excuse})"+    );+}++/// SEVERITY 1 — THE POST-SWEEP RE-MEASURE DROPS TWO OF THE THREE CONDITIONS THE+/// GATE'S OWN PREAMBLE REQUIRES.+///+/// `tools/quit-gate.py` states its first claim at the top of the file:+///+/// > 1. ZERO DOCS  documentCount == 0 AND unsavedDocuments == 0 AND the count+/// >               AGREES with the enumerated list (a truncated enumeration is …)+///+/// Before the sweep it checks all three. After the sweep it checks one:+///+/// ```python+/// state = verb("swx_get_app_state", {})+/// count = state.get("documentCount")+/// unsaved = state.get("unsavedDocuments")+/// if count != 0:+///     raise Refused(…)+/// ```+///+/// `unsaved` is reassigned and never looked at again. `documentsTruncated` is+/// not re-read. And `listed` — the enumeration the count is supposed to agree+/// with — is never re-fetched, so the later `len(listed) != 0` cross-check is+/// comparing the post-sweep count against the PRE-sweep list. A session that+/// reports 0 documents from a truncated enumeration, or 0 documents with a+/// non-zero unsaved count, proceeds to the quit ladder.+///+/// The sweep is exactly the operation that makes a stale enumeration likely: it+/// closes documents, so the list it was measured against is guaranteed out of+/// date. The fix is to re-run the same three-condition check rather than a+/// weaker one — the function to call already exists a hundred lines above.+#[test]+fn the_quit_gate_re_measures_everything_it_measured_the_first_time() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let src = std::fs::read_to_string(root.join("tools/quit-gate.py")).expect("quit-gate.py");+    let after = src+        .split("# RE-MEASURE.")+        .nth(1)+        .expect("the re-measure exists")+        .split("# A DRAIN THAT MATCHED NOTHING")+        .next()+        .unwrap()+        .to_string();++    let rechecks_unsaved = after.contains("unsaved != 0") || after.contains("unsaved");+    let rechecks_truncation = after.contains("truncated") || after.contains("Truncated");+    let refetches_listing = after.contains("docs(") || after.contains("swx_docs");++    assert!(+        rechecks_truncation && refetches_listing,+        "the post-sweep re-measure checks documentCount only. The gate's own preamble requires \+         THREE things — count 0, unsaved 0, and the count agreeing with the enumeration, with a \+         truncated enumeration disqualifying — and the re-measure re-reads `unsavedDocuments` \+         into a variable it never tests, never re-reads `documentsTruncated`, and never re-fetches \+         the listing, so the later cross-check compares a post-sweep count against a PRE-sweep \+         list. The sweep is the very thing that invalidates that list. \+         (unsaved: {rechecks_unsaved}, truncation: {rechecks_truncation}, refetch: \+         {refetches_listing})\n--- re-measure block ---\n{after}"+    );+}++/// SEVERITY 2 — THE QUIT LADDER REPORTS A DIALOG WITHOUT READING WHAT IT SAYS.+///+/// `read_contents` has exactly one caller in the tree: `blocking_dialog_now`+/// (verbs3.rs:658), which serves the SWEEP and the import. The quit ladder builds+/// its dialog at verbs.rs:1136 and :1543 and serialises it at :1184 with+/// `to_json()` — never calling `read_contents` — so `text`, `buttons` and+/// `acknowledgeOnly` come back null and `contentsReadable` false.+///+/// That is the defect 0.8.4 fixed for the sweep, in the other half of the same+/// release. The commit's own words: a title alone cannot tell "your licence+/// expired" from "the licence server is unreachable and the session ends after+/// five more tries". The quit ladder is where a stuck session is diagnosed, and+/// it reports the title alone.+#[test]+fn a_quit_that_stops_on_a_dialog_says_what_the_dialog_said() {+    let machine = TempDir::new("adv32-quit-dialog-machine");+    std::fs::write(+        machine.join("dialog-open.json"),+        json!({+            "hwnd": 9001, "className": "#32770", "title": "License Manager", "visible": true,+            "text": ["Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional: \+                      Unable to connect to server. The session will end after the next 5 \+                      unsuccessful attempts."],+            "buttons": ["OK"],+        })+        .to_string(),+    )+    .unwrap();+    let scratch = TempDir::new("adv32-quit-dialog");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    let r = b.call("swx_stop", json!({"reason": "adversary: a dialog is up"}));+    let text = s(&r);+    assert!(+        text.contains("License Manager"),+        "precondition: the quit reports the dialog at all: {text}"+    );++    assert!(+        text.contains("unsuccessful attempts") || text.contains("\"contentsReadable\": true"),+        "the quit reports the dialog by TITLE and nothing else — read_contents is never called \+         on this path, so text, buttons and acknowledgeOnly are null and contentsReadable is \+         false. 0.8.4 fixed exactly this for the sweep, in the same release, for the reason its \+         own commit gives: a title cannot tell \"your licence expired\" from \"the licence server \+         is unreachable and the session ends after five more tries\". The quit ladder is where a \+         stuck session gets diagnosed. Reply: {text}"+    );+}++// ── PASS 34: tools/drain-gate.py --fixture new, the twin measurement ─────────+//+// SOURCE-SCAN TESTS. This gate drives the live desktop — it opens and closes+// documents in Drew's session — so I read it and do not run it. Every claim+// below is a structural property of the file, cited by line.++fn gate_src() -> String {+    std::fs::read_to_string(+        Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/drain-gate.py"),+    )+    .expect("tools/drain-gate.py")+}++/// SEVERITY 1 — CLEANUP IS NOT ON A FINALLY, SO A REFUSAL LEAVES EVERYTHING.+///+/// `STEP 6 — CLEANUP` lives inside `main()` behind `if not a.keep:`. Every+/// `Refused` in this file propagates to the `__main__` handler, which prints and+/// `sys.exit(2)` — main's cleanup is never reached. The same is true of any+/// unhandled exception, and of a `KeyboardInterrupt` on a gate that makes+/// minutes-long PowerShell calls.+///+/// This is not hypothetical: it is the mechanism behind the leftovers seen live.+/// The gate REFUSES often and by design — a dialog on screen, a truncated+/// enumeration, `interconnect_blocks_import`, an identity mismatch — and on this+/// machine right now it refuses every run, because a License Manager dialog is+/// up. So the litter accumulates on exactly the runs that are working correctly.+///+/// Worse, the cleanup also CLOSES the documents the gate opened. Skipping it+/// does not merely leave files: it leaves the gate's own documents open in the+/// user's SOLIDWORKS, which is the state the next run then has to reason about.+///+/// A `try/finally` around STEP 6, or an `atexit` registered the moment the+/// scratch directory is made, costs a line and removes the whole class.+#[test]+fn the_drain_gate_cleans_up_even_when_it_refuses() {+    let src = gate_src();+    let cleanup = src.find("STEP 6 — CLEANUP").expect("the cleanup step exists");+    let head = &src[..cleanup];+    // Either a finally/atexit guards it, or it is unreachable on the refusal path.+    let guarded = head.contains("finally:") || src.contains("atexit.register");+    assert!(+        guarded,+        "STEP 6 — CLEANUP sits inside main() behind `if not a.keep:`, and every Refused in this \+         file is caught at __main__ with sys.exit(2), so the cleanup never runs on a refusal — \+         nor on a crash or a Ctrl-C. The gate refuses BY DESIGN (a dialog on screen, a truncated \+         enumeration, an identity mismatch) and on this machine it refuses every run right now, \+         so the litter accumulates on exactly the runs that are behaving correctly. It is not \+         only files: STEP 6 also CLOSES the documents this gate opened, so a refusal leaves them \+         open in the user's session. try/finally, or atexit at the moment the scratch dir is made."+    );+}++/// SEVERITY 1 — THE GATE CALLS ITS OWN FILES FOREIGN.+///+/// ```python+/// wrote = [w for w in [FIXTURE_STEM + ".step"] + ([twin_name] if twin_open else [])+///          if str(w or "").strip()]+/// ```+///+/// `twin_name` is assigned in the `twin_open` branch (line 387) — so when the+/// probe is REFUSED, which is the measured behaviour on this edition+/// (`swFileWithSameTitleAlreadyOpen = 65536`), the copy the gate itself made at+/// `twin/<base>` is not in `wrote`. It is not removed, `twin_dir` is therefore+/// not empty and is not removed, and the file then appears in `left` under:+///+/// > "%TEMP%\swx-drain-gate still holds files this gate did not write, so the+/// > directory was left alone"+///+/// The gate wrote it. It wrote it four lines after deciding to. And because the+/// scratch directory is a FIXED path — `%TEMP%\swx-drain-gate`, not a unique one+/// per run — the same message also reports files left by earlier crashed runs of+/// this same gate as foreign. That was the live observation: `twin`,+/// `Part5-…`, `Part7-…`.+///+/// The claim matters because it is what stops the cleanup: the gate declines to+/// remove a directory whose contents it believes somebody else wrote, which is+/// the right instinct applied to a wrong fact. Two fixes, either sufficient:+/// write a `manifest.json` of what this run created and clean by it, or make the+/// scratch directory unique per run so anything in it is by definition this+/// run's.+#[test]+fn the_drain_gate_knows_which_files_are_its_own() {+    let src = gate_src();+    // BUILDER NOTE, 0.8.5: the `wrote` list is GONE — replaced by a per-run+    // manifest, which is the third of the three fixes this test offers. So its+    // absence is not a missing precondition, it is the fix; the detector treats+    // it that way rather than panicking on a shape that no longer exists.+    let wrote = src+        .find("wrote = [w for w in")+        .map(|i| src[i..].lines().take(2).collect::<Vec<_>>().join(" "))+        .unwrap_or_else(|| "(no `wrote` list: replaced by a per-run manifest)".to_string());++    let twin_is_conditional = wrote.contains("if twin_open");+    let has_manifest = src.contains("manifest");+    let unique_scratch = src.contains("mkdtemp") || src.contains("swx-drain-gate-%");++    // Any ONE of these closes it: attribute the twin unconditionally, keep a+    // manifest of what this run wrote, or make the scratch dir unique per run.+    assert!(+        !twin_is_conditional || has_manifest || unique_scratch,+        "the twin copy is attributed to this gate only `if twin_open`, so the REFUSED probe — the \+         measured behaviour on this edition, swFileWithSameTitleAlreadyOpen = 65536 — leaves the \+         file the gate copied itself, then reports it as one \"this gate did not write\". The \+         scratch directory is a fixed %TEMP%\\swx-drain-gate rather than unique per run, so the \+         same message also calls earlier crashed runs' files foreign (seen live: twin, Part5-…, \+         Part7-…). That wrong fact is what STOPS the cleanup — the gate declines to touch a \+         directory it thinks is someone else's. Record a manifest of what this run wrote, or make \+         the scratch directory unique per run. (twin conditional: {twin_is_conditional}, \+         manifest: {has_manifest}, unique scratch: {unique_scratch})\n  wrote = {wrote}"+    );+}++/// SEVERITY 2 — THE MEASUREMENT IS LABELLED "MEASURED" AND KEPT NOWHERE.+///+/// The 65536 result goes into `NOTES`, which is printed to stdout at the end of+/// the run and then lost. There is no date on it and no record of the invocation+/// that produced it — the note names the error code and message, but not that it+/// was `swx_open {path: <twin dir>/<basename>}` after copying a document's own+/// file to a second directory, which is the only thing that makes the result+/// reproducible.+///+/// Everywhere else in this project a measurement is written down where the code+/// that depends on it lives, with its date: `com.rs` carries "measured live+/// 2026-09-17: TRUE" beside the toggle id, and `disk.rs` carries the size table.+/// That is why those facts survived three releases of refactoring. This one has+/// to be re-derived from a terminal scrollback.+///+/// It also decides something structural: if SAVED documents cannot collide, then+/// the drain's title-collision guard only ever protects PATHLESS documents — the+/// note says exactly that, and it is a claim about which code paths are+/// reachable. A claim of that weight belongs next to the guard.+///+/// (The honesty of the branches is fine and worth saying: a third error code is+/// recorded as "UNMEASURED this run" rather than folded into either answer, and+/// a success is recorded as the affirmative measurement. The cleanup closes+/// by PATH, and only paths the gate created — `temp_paths` plus its own `dup`.)+#[test]+fn the_same_title_measurement_is_written_down_where_it_is_used() {+    let src = gate_src();+    let note = src+        .split("MEASURED: SOLIDWORKS REFUSED")+        .nth(1)+        .map(|s| s.chars().take(700).collect::<String>())+        .expect("the refusal note exists");++    let has_date = note.contains("2026-") || note.contains("%s-%s-%s") || note.contains("date");+    let has_invocation = note.contains("swx_open") || note.contains("invocation");++    assert!(+        has_date && has_invocation,+        "the 65536 result is labelled MEASURED and carries neither the DATE it was measured nor \+         the INVOCATION that produced it, and it lives in NOTES — printed at the end of a run and \+         then gone. Every other measurement in this project is written beside the code that \+         depends on it, dated: com.rs has \"measured live 2026-09-17: TRUE\" next to the toggle \+         id, disk.rs has the size table. This one says something structural — that a saved-document \+         title collision is IMPOSSIBLE on this edition, so the drain's collision guard only ever \+         protects pathless documents — and a claim of that weight belongs next to the guard. \+         (date: {has_date}, invocation: {has_invocation})"+    );+}
tests/adversary33.rsadded+290
@@ -0,0 +1,290 @@+//! ADVERSARIAL REVIEW OF 0.8.5 — pass thirty-five.+//!+//! RELEASE HYGIENE (target 5) IS CLEAN, and I checked it properly rather than+//! by eye:+//!+//!   * `bridge.json` declares 66 verbs; the server dispatches 66; the two sets+//!     are EQUAL — nothing declared that cannot be called, nothing callable that+//!     ab cannot see.+//!   * `verbPrefixes: ["swx_"]` covers all 66. `statusVerb: "swx_status"` exists.+//!   * Version is 0.8.5 in all four places that carry one: `Cargo.toml`, the+//!     `BRIDGE_VERSION` file, `bridge.json`, and `BRIDGE_VERSION` in `lib.rs`+//!     (which is `env!("CARGO_PKG_VERSION")`, so it cannot drift).+//!   * The only file `bridge.json` names is `swx-bridge.exe`, the built artifact.+//!     Its other paths are SOLIDWORKS install locations on the USER's machine+//!     and the manifest URL, neither of which belongs in the zip.+//!   * `resources/macros/adom-hello.swp` is present, and the one code path that+//!     needs a macro (`quit` rung e) already degrades to `skipped` with a reason+//!     when it is absent, so a zip missing it would refuse rather than misbehave.+//!+//! THE DETECTOR ADAPTATIONS (the coordinator's real question) mostly HELD. The+//! never-ask pin now joins `\`-continued literals, walks the repo-root markdown,+//! and its dialog exemption gained the negative conjuncts that close the hole I+//! reported — "answer or dismiss a DIALOG, and not also close the documents".+//! That is a stronger pin, not a weaker one. The residual is below.+//!+//! The drain gate's manifest is well designed on the point I expected to break:+//! `remember()` records a creation BEFORE making it, so a crash in between+//! leaves a PHANTOM entry rather than a missing one — and cleanup tolerates+//! `document_not_open` / `no_target_document` and wraps every removal in a+//! swallow. A phantom is harmless; the reverse would not have been.++mod common;+use serde_json::json;+use std::path::Path;++fn gate_src() -> String {+    std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/drain-gate.py"))+        .expect("tools/drain-gate.py")+}++/// SEVERITY 1 — THE TWO FIXES CANCEL: PRIOR-RUN RECOVERY CAN NEVER FIRE.+///+/// 0.8.5 took two of the three fixes I offered in pass 34, and they are mutually+/// exclusive as built.+///+///   * the scratch root is now UNIQUE PER RUN:+///     `swx-drain-gate-<pid>-<epoch>` (drain-gate.py:392);+///   * `prior_manifests(root)` — "Manifests left by earlier runs — their+///     leftovers are ours too" — lists `run-*.json` inside **that same unique+///     root** (:255, called at :279).+///+/// A directory created seconds ago by this process contains no earlier run's+/// manifest, and the one manifest it does contain is excluded by+/// `p != MANIFEST_PATH`. So the whole adoption path is unreachable. Every+/// crashed run's leftovers sit in ITS OWN `swx-drain-gate-<pid>-<time>`+/// directory that nothing will ever look at again.+///+/// `atexit` covers the cases that mattered most — a refusal, an exception, a+/// Ctrl-C — and that is the important half and it works. It does not cover+/// SIGKILL, a hard interpreter crash, or a reboot, which is exactly what+/// `prior_manifests` was written for. And the unique root makes the residue+/// worse in one respect: crashed runs now leave a DIRECTORY each instead of+/// sharing one.+///+/// One line: scan `tempfile.gettempdir()` for `swx-drain-gate-*`, not just this+/// run's root. Do it together with the containment check in the next test —+/// fixing this one without that one is what turns it into a real hazard.+#[test]+fn the_gate_can_still_find_leftovers_from_a_run_that_died_hard() {+    let src = gate_src();+    let unique_root = src.contains("swx-drain-gate-%d-%d") || src.contains("swx-drain-gate-{");+    // NOT `contains("glob")` — this file has `global _CLEANED` and `global LOCAL`,+    // and matching those is how my first version of this test PASSED on a bug+    // that is really there. Look for a scan of the TEMP ROOT specifically.+    let scans_temp_root = src.contains("glob.glob")+        || src.contains("iglob")+        || src.contains("listdir(tempfile.gettempdir())")+        || src.contains("listdir(TEMP_ROOT")+        || src.contains("prior_manifests(tempfile.gettempdir())")+        || src.contains("prior_manifests(TEMP_ROOT");++    assert!(+        !unique_root || scans_temp_root,+        "the scratch root is unique per run (swx-drain-gate-<pid>-<epoch>) and prior_manifests() \+         lists run-*.json inside THAT root, so it can only ever see this run's own manifest — \+         which the caller then filters out with `p != MANIFEST_PATH`. The adoption path is \+         unreachable: \"Manifests left by earlier runs — their leftovers are ours too\" never \+         adopts anything. atexit covers refusals, exceptions and Ctrl-C, which is the half that \+         mattered and it works; it does not cover SIGKILL, a hard crash or a reboot, which is \+         what prior_manifests exists for. And a unique root means each such run now leaves its \+         own DIRECTORY behind. Scan the temp root for swx-drain-gate-*. \+         (unique root: {unique_root}, scans temp root: {scans_temp_root})"+    );+}++/// SEVERITY 1, FORWARD-LOOKING — THE USER-WRITE OPT-IN HAS NO CONTAINMENT CHECK.+///+/// `cleanup` closes every document in every manifest with the full escalation:+///+/// ```python+/// c = verb("swx_close_doc", {+///     "path": doc,+///     "allowUserWrites": True,+///     "confirm": True,+///     "reason": "… (its own manifest is the ownership proof)",+/// })+/// ```+///+/// Two problems, and the second is only latent because the first test's bug is+/// keeping it that way.+///+/// The opt-in is applied to EVERY document in the manifest, though the comment+/// beside it explains that only the TWIN needs it — "Most of these the bridge+/// created and can close in the default posture." Widening a user-write+/// escalation past the one case that requires it is the opposite of how every+/// other gate in this project is written.+///+/// And the target list comes from a FILE ON DISK with nothing binding it: no+/// check that `doc` lies under the gate's own scratch root, no pid, no+/// timestamp. "Its own manifest is the ownership proof" is the claim; the code+/// verifies neither that the manifest is its own nor that the path is somewhere+/// it could have written. Today `prior_manifests` is dead, so the only manifest+/// read is the one this process just wrote — which is why this is latent rather+/// than live. Fix the previous finding without this one and the gate starts+/// reading `run-*.json` out of `%TEMP%`, a directory anything can write to, and+/// feeding those paths to a confirmed user-write close.+///+/// This bridge's whole doctrine is "no handle, no kill" — attribution before+/// action. One `os.path.commonpath` against the scratch root restores it.+#[test]+fn the_gates_user_write_close_is_scoped_to_what_it_could_have_written() {+    let src = gate_src();+    let block = src+        .split("def cleanup(")+        .nth(1)+        .map(|s| s.chars().take(2600).collect::<String>())+        .expect("cleanup exists");++    let uses_opt_in = block.contains("\"allowUserWrites\": True");+    let contained = block.contains("commonpath")+        || block.contains("startswith(scratch_root")+        || block.contains("_is_ours(");+    let opt_in_scoped_to_twin = block.contains("doc == twin") || block.contains("is_twin");++    assert!(+        !uses_opt_in || (contained && opt_in_scoped_to_twin),+        "cleanup passes allowUserWrites+confirm for EVERY document in EVERY manifest, though its \+         own comment says only the TWIN needs it, and it never checks that the path lies under \+         the scratch root it created. The target list is a JSON file on disk and \"its own \+         manifest is the ownership proof\" is asserted, not verified. It is latent only because \+         prior_manifests is currently unreachable — fix that without this and the gate will read \+         run-*.json out of %TEMP% and feed those paths to a confirmed user-write close. This \+         project's doctrine is attribution before action. \+         (opt-in: {uses_opt_in}, containment: {contained}, scoped to twin: {opt_in_scoped_to_twin})"+    );+}++/// SEVERITY 2 — THE PIN PASSES BECAUSE ITS EXEMPTION LIST GREW UNTIL IT DID.+///+/// The rewrite is a real improvement and I want to say so plainly: joining+/// `\`-continued literals closed the wrapping hole, the repo-root markdown is+/// scanned, and the dialog exemption is now a conjunction —+/// `(answer|dismiss) && dialog && !close the document && !close them &&+/// !close their && !by hand` — which is precisely the narrowing I asked for.+///+/// What came with it is eight ad-hoc escape hatches, added so the pin would pass+/// over this project's own commentary:+///+/// ```ignore+/// || l.contains("used to end here") || l.contains("passed.")+/// || l.contains("is not, and") || l.contains("a test now fails")+/// || l.contains("restart") || l.contains("their screen")+/// || l.contains("has closed") || l.contains("was closed")+/// ```+///+/// MEASURED, by planting each sentence in `src/verbs3.rs` and running the pin:+///+/// ```text+/// "A dialog is up, so ask the user to \ close the documents by hand."   CAUGHT+///   (same sentence as a SKILL.md list item)                             CAUGHT+/// "The sweep passed. Ask the user to close the remaining documents."    SLIPS+/// "After the restart, ask the user to close the leftover documents."    SLIPS+/// ```+///+/// The first two are the holes I reported in pass 33, and the rewrite genuinely+/// closes them — including the wrapped form and the repo-root markdown. The+/// second two are the new exemptions, and they are not theoretical:+/// `l.contains("passed.")` exempts any statement containing those seven+/// characters. *"The sweep passed. Ask the user to close the remaining+/// documents."* is exempt. `l.contains("restart")` exempts *"after the restart,+/// ask the user to close the leftover documents"*. These are not principled+/// categories like the dialog one; they are the text of particular sentences+/// that exist today, and each is a permanent hole shaped like a phrase.+///+/// The structural fix is the one every classifier in this project already has+/// and this one does not: a TABLE OF CASES. `classify_window` is pinned by+/// `cases: &[(&str, u32, &str, Option<&str>)]` twenty lines below — known input,+/// expected verdict. The same table here, holding the sentence from my pass-33+/// report and the legitimate ones each exemption was added for, would make every+/// future exemption provably safe instead of provably convenient. Without it the+/// only way to know whether this pin works is to read it, which is what made the+/// original version wrong for a week.+#[test]+fn the_never_ask_pin_is_pinned_by_examples_not_by_reading() {+    let src = std::fs::read_to_string(+        Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/wiring.rs"),+    )+    .expect("wiring.rs");+    let pin = src+        .split("fn nothing_asks_the_user_to_close_documents")+        .nth(1)+        .map(|s| s.chars().take(6000).collect::<String>())+        .expect("the pin exists");++    // A sentence the rule forbids, and one it must allow: does the pin carry+    // either as a case it actually evaluates?+    let has_cases = pin.contains("cases") || pin.contains("EXAMPLES")+        || pin.contains("known_bad") || pin.contains("must_flag");+    let broad_escape = pin.contains(r#"l.contains("passed.")"#)+        || pin.contains(r#"l.contains("restart")"#);++    assert!(+        has_cases && !broad_escape,+        "the pin has no table of example sentences, and it acquired eight phrase-shaped \+         exemptions so it would pass over this project's own prose. `l.contains(\"passed.\")` \+         exempts \"The sweep passed. Ask the user to close the remaining documents.\"; \+         `l.contains(\"restart\")` exempts \"after the restart, ask the user to close the \+         leftover documents\". The dialog exemption IS principled — a conjunction with three \+         negative conjuncts — and these are not; they are the text of sentences that happen to \+         exist. classify_window twenty lines below is pinned by a `cases` table of input and \+         expected verdict. The same table here, carrying the sentence from my pass-33 report \+         and the legitimate ones each exemption was added for, makes every future exemption \+         provably safe rather than provably convenient. \+         (cases: {has_cases}, broad escapes: {broad_escape})"+    );+}++/// The release manifest and the server agree, checked rather than assumed.+/// Passing today — kept because nothing else in the suite compares them, and a+/// verb missing from `bridge.json` is invisible to ab with no other symptom.+#[test]+fn every_verb_the_server_dispatches_is_declared_to_ab() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let manifest: serde_json::Value =+        serde_json::from_str(&std::fs::read_to_string(root.join("bridge.json")).expect("bridge.json"))+            .expect("bridge.json parses");+    let declared: std::collections::BTreeSet<String> = manifest["verbs"]+        .as_array()+        .expect("verbs is an array")+        .iter()+        .filter_map(|v| v.as_str().map(|s| s.to_string()))+        .collect();++    let mut dispatched: std::collections::BTreeSet<String> = Default::default();+    for f in ["verbs.rs", "verbs2.rs", "verbs3.rs"] {+        let p = root.join("src").join(f);+        let Ok(text) = std::fs::read_to_string(&p) else { continue };+        for line in text.lines() {+            if let Some(i) = line.find("\"swx_") {+                let rest = &line[i + 1..];+                if let Some(j) = rest.find('"') {+                    let name = &rest[..j];+                    if rest[j..].trim_start_matches('"').trim_start().starts_with("=>") {+                        dispatched.insert(name.to_string());+                    }+                }+            }+        }+    }++    assert!(+        !dispatched.is_empty(),+        "precondition: the dispatch table was found"+    );+    let undeclared: Vec<&String> = dispatched.difference(&declared).collect();+    let uncallable: Vec<&String> = declared.difference(&dispatched).collect();+    assert!(+        undeclared.is_empty() && uncallable.is_empty(),+        "bridge.json and the server's dispatch table disagree. A verb the server dispatches but \+         bridge.json does not declare is INVISIBLE to ab — it simply does not appear, with no \+         error anywhere. One declared but not dispatched is advertised and fails on use.\n  \+         dispatched, undeclared: {undeclared:?}\n  declared, not dispatched: {uncallable:?}"+    );+    assert_eq!(+        manifest["version"].as_str(),+        Some(env!("CARGO_PKG_VERSION")),+        "bridge.json's version must match the crate it ships"+    );+    let _ = json!({});+}
tests/adversary34.rsadded+205
@@ -0,0 +1,205 @@+//! ADVERSARIAL REVIEW OF 0.8.6 — pass thirty-six.+//!+//! WHAT CHECKS OUT.+//!+//! (1) S2's split is sound, and for the reason the comment claims. A prior+//!     manifest CANNOT get a user document closed by calling it bridge-created:+//!     the default-posture branch goes to `swx_close_doc` without the opt-in,+//!     and `verbs.rs:1917` does `if !target.owned { authorize_in(…) }` — the+//!     BRIDGE refuses, not the gate. Ownership is the bridge's registry, which+//!     is session-scoped, so a document from a dead run's session is not owned+//!     in this one either. And on that refusal the gate does NOT retry with the+//!     opt-in: it appends to `NOTES` and moves on. The one thing I would have+//!     expected to go wrong here does not.+//!+//! (3) The `finally` fix is right, and the comment explains the subtlety+//!     correctly: `return 1` inside `finally` overrides the value the `try`+//!     block already returned, where assigning to `code` would not. The verdict+//!     line is CONTRADICTED rather than corrected — "GATE PASSED" is printed by+//!     `main()` before the `finally` runs, so a refused cleanup reads+//!     "GATE PASSED" then "GATE EXIT RAISED TO 1". The second line is adjacent+//!     and unambiguous and the exit code is right, so I would leave it.+//!+//! (5) Pass 33's adapted detector is still a real test: it asserts the `cases`+//!     table carries that finding's own sentence as a must-CATCH row, so+//!     deleting the row fails it. That is a stronger check than the spelling+//!     test it replaced.+//!+//! WHAT DOES NOT.++use std::path::Path;++fn wiring() -> String {+    std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/wiring.rs"))+        .expect("wiring.rs")+}++/// SEVERITY 1 — THE PIN IS RED ON MASTER, ON PROSE THAT DOCUMENTS THE RULE.+///+/// `nothing_asks_the_user_to_close_documents` FAILS on a clean checkout of+/// cb969e5e. I ran it with `git status` empty; the offender is not planted:+///+/// > README.md:454: … two were working bypasses — *"The sweep passed. Ask the+/// > user to close the remaining documents."* and *"After the restart, ask the+/// > user to close the leftover documents."* both slipped through.+///+/// That is the changelog entry describing my pass-35 finding, quoting the two+/// sentences as examples of what the pin must catch. Quoting a violation is not+/// committing one, and the pin has no notion of a quoted example — so the act of+/// writing down what the rule forbids breaks the rule's own detector.+///+/// This is precisely the failure the coordinator named: a false positive is how+/// a pin gets disabled. The next person to meet this red test has three moves,+/// and two of them are bad — add a ninth exemption (which is what the phrase+/// exemptions were, and what 0.8.6 just finished removing), or delete the+/// sentences from the changelog so the record of the fix no longer says what was+/// fixed. The third is the real one: the `cases` table already carries both+/// sentences as must-catch rows, so the pin should skip MARKDOWN QUOTES — a+/// blockquote, an inline code span, or an emphasised run inside a longer+/// sentence — and let the table be where example violations live.+///+/// It also means the suite is not green at the commit under review, which no+/// report on 0.8.6 should omit.+#[test]+fn the_never_ask_pin_is_not_tripped_by_documentation_of_itself() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let readme = std::fs::read_to_string(root.join("README.md")).expect("README.md");+    // WHITESPACE-NORMALISED. The README wraps this sentence across two lines —+    // "close the remaining\n  documents." — which is exactly why the pin's+    // paragraph joining sees it and my first exact-substring check did not. That+    // check PASSED on a test that is genuinely failing, so it is spelled out.+    let flat = readme.split_whitespace().collect::<Vec<_>>().join(" ");+    let quotes_an_example = flat.contains("Ask the user to close the remaining documents");+    let pin = wiring();+    // Does the pin know what a quotation looks like?+    let understands_quotes = pin.contains("is_quoted")+        || pin.contains("blockquote")+        || pin.contains("strip_quoted")+        || pin.contains("*\\\"");++    assert!(+        !quotes_an_example || understands_quotes,+        "README.md quotes the two bypass sentences as EXAMPLES of what the pin must catch, and \+         the pin flags the README for containing them — it fails on a clean checkout of this \+         commit, with git status empty. Quoting a violation is not committing one. The next \+         person to meet a red pin either adds a ninth exemption (the thing 0.8.6 just removed) \+         or edits the changelog so the record of the fix stops saying what was fixed. Teach it to \+         skip markdown quotes and let the `cases` table hold example violations — it already \+         carries both sentences as must-catch rows. \+         (README quotes an example: {quotes_an_example}, pin understands quotes: \+         {understands_quotes})"+    );+}++/// SEVERITY 1 — THE POSITIONAL RULE IS DIRECTIONAL, AND ENGLISH IS NOT.+///+/// MEASURED, by planting each sentence in `src/verbs3.rs` and counting+/// offenders from that file (the README failure above is filtered out):+///+/// ```text+/// "The sweep passed. Ask the user to close the remaining documents."   CAUGHT+/// "After the restart, ask the user to close the leftover documents."   CAUGHT+/// "Ask the user to run the sweep, which will close the documents."     CAUGHT+/// "The bridge closes; it never asks the user to close."                allowed  (correct)+/// "Close the documents (ask the user)."                                SLIPS+/// ```+///+/// Both of my pass-35 bypasses are genuinely closed, a clause-separated ask is+/// caught, and the legitimate rule-stating sentence is correctly allowed — the+/// rewrite is a real improvement and the false-positive risk the coordinator+/// asked about does not materialise for that sentence.+///+/// But requiring the close to FOLLOW the ask makes word order the whole test,+/// and an imperative can lead with its object. "Close the documents (ask the+/// user)." is the same instruction, is the form an agent-facing hint would+/// plausibly take — imperative first, actor in parentheses — and walks straight+/// past. So do "Closing is the user's: ask them." and any sentence where the+/// verb is fronted.+///+/// The fix is not another position rule. It is to keep the positional rule for+/// what it is good at and add the reverse order as its mirror — ask and close in+/// the same statement in EITHER order, with the existing table to prove neither+/// direction over-catches. The table is the right machinery and it only has rows+/// for one direction.+#[test]+fn the_positional_rule_catches_an_ask_that_leads_with_the_close() {+    let pin = wiring();+    // BUILDER NOTE, 0.8.7: the `cases` table lives in+    // `the_never_ask_rule_is_pinned_by_examples`, which sits BEFORE the pin+    // function — so splitting on the pin's name and reading the tail misses it.+    // The whole file is the right scope: the question is whether the table has a+    // reversed-order row anywhere, not where the table sits.+    let table = pin.clone();++    // A must-catch row whose CLOSE precedes its ASK.+    let has_reversed_case = table.contains("(ask the user)")+        || table.contains("ask them.")+        || table.contains("reversed");++    assert!(+        has_reversed_case,+        "the rule requires the close to FOLLOW the ask, so \"Close the documents (ask the \+         user).\" — measured, planted in src/verbs3.rs — produces no offender. An imperative can \+         lead with its object, and an agent-facing hint plausibly would: verb first, actor in \+         parentheses. Both pass-35 bypasses ARE now caught and the legitimate \"it never asks the \+         user to close\" is correctly allowed, so the rewrite works; it is one-directional. Add \+         the mirror order and a must-catch row for it — the `cases` table is the right machinery \+         and has rows for one direction only."+    );+}++/// SEVERITY 3 — THE TWIN OPT-IN IS PATH-SCOPED BUT NOT SESSION-SCOPED.+///+/// Everywhere else the bridge's ownership registry is the proof, and it is+/// session-scoped: a document created by a previous SOLIDWORKS session is not+/// owned in this one, which is what makes the default-posture branch safe for a+/// prior manifest (finding note (1) above).+///+/// The twin branch is the one place that proof is deliberately bypassed —+/// `allowUserWrites: True, confirm: True` is how a user document gets closed —+/// and what replaces it is `commonpath` containment alone. The manifest records+/// the GATE's pid in its filename and nothing about the SOLIDWORKS session, so a+/// twin path from a dead run is force-closed in whatever session is running now+/// if a document is open at that path.+///+/// Exposure today is small and I want to be accurate about that: twin paths are+/// `<parent>/run-<pid>-<epoch>/twin-…`, so a collision needs pid reuse within the+/// same second, and the file would have to be open. It is a design gap rather+/// than a live hazard. It is worth a line because it is the ONE place in this+/// tool where a confirmed user-write is authorised by something other than the+/// bridge's own ownership answer, and the cheap fix — record the SOLIDWORKS+/// process id beside the twin and skip the opt-in when it differs — costs one+/// field in a file the gate already writes.+#[test]+fn a_twin_from_another_session_is_not_force_closed() {+    let src = std::fs::read_to_string(+        Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/drain-gate.py"),+    )+    .expect("drain-gate.py");+    // THE WHOLE FUNCTION, not a fixed window: `allowUserWrites` sits at offset+    // 4043 and my first version read 3000 characters, so the test passed by+    // never reaching the line it is about.+    let cleanup = src+        .split("def cleanup(")+        .nth(1)+        .map(|s| s.split("\ndef ").next().unwrap_or(s).to_string())+        .expect("cleanup exists");++    let uses_opt_in = cleanup.contains("\"allowUserWrites\": True");+    let session_scoped = cleanup.contains("sessionPid")+        || cleanup.contains("session_pid")+        || cleanup.contains("processId");++    assert!(+        !uses_opt_in || session_scoped,+        "the twin close passes allowUserWrites+confirm, which is the one place this tool \+         overrides the bridge's ownership answer, and it is gated on PATH containment only. The \+         manifest records the gate's pid in its filename and nothing about the SOLIDWORKS \+         session, so a twin path written by a dead run is force-closed in whatever session is \+         running now. Exposure is small — twin paths carry a pid and an epoch second — but this \+         is the single place a confirmed user-write is authorised by something other than the \+         bridge saying the document is its own. Record the SOLIDWORKS process id beside the twin \+         and skip the opt-in when it differs. \+         (opt-in: {uses_opt_in}, session-scoped: {session_scoped})"+    );+}
tests/adversary35.rsadded+160
@@ -0,0 +1,160 @@+//! ADVERSARIAL REVIEW OF 0.8.7 — pass thirty-seven.+//!+//! MASTER IS GREEN: 396 pass, 0 fail, on a clean checkout of bf73d80. Verified+//! before anything below.+//!+//! WHAT CHECKS OUT.+//!+//! (2) The clause window holds in both directions. Measured, planted in+//!     `src/verbs3.rs`:+//!+//!     ```text+//!     "Ask the user, who knows the session, whether the sweep ran, and then+//!      close the documents."                                          CAUGHT+//!     "Close the documents (ask the user)."                           CAUGHT+//!     ```+//!+//!     Three clauses apart is still caught, and my pass-36 reversed-order+//!     bypass is closed.+//!+//! (3) The session check is a CONJUNCTION, not an alternative:+//!     `if doc in twin_paths and inside and same_session:` (drain-gate.py:405).+//!     A forged `sessionPid` still has to clear `commonpath` containment, and an+//!     unknown session is `None` which fails the equality. Both conditions+//!     required, exactly as intended.+//!+//! (4) NOTHING asserts the README's test count. I grepped every test for it:+//!     the only README references are this review's own detectors and the pin's+//!     file list. There is no self-referential count pin to remove.+//!+//! WHAT DOES NOT.++use std::path::Path;++/// SEVERITY 1, CONSENT — A QUOTED INSTRUCTION IN `SKILL.md` IS STILL AN+/// INSTRUCTION, AND THE PIN NOW SKIPS IT.+///+/// MEASURED, by appending each to `SKILL.md` and counting offenders from that+/// file:+///+/// ```text+/// "If blocked, ask the user to close the documents by hand."      CAUGHT (control)+/// fenced block containing the same sentence                       CAUGHT+/// "> If a dialog is up, ask the user to close the documents…"     SLIPS+/// "Run `ask the user to close the documents` when blocked."       SLIPS+/// ```+///+/// The 0.8.6 fix was right for `README.md`, where the quoted sentences are a+/// CHANGELOG describing what the pin must catch. It is wrong for `SKILL.md`,+/// because `SKILL.md` is not documentation about the bridge — it is the file an+/// agent loads and follows. Markdown emphasis is presentational; a model reading+/// a blockquoted "ask the user to close the documents by hand" reads an+/// instruction, and a backticked one reads an instruction it should probably run+/// verbatim. The two files have opposite relationships to their own quotes and+/// the pin now treats them the same way.+///+/// So the category stripping has made the pin blind in exactly the file that+/// matters, to close a false positive that only ever existed in the other one.+///+/// THE HONEST SPLIT, which is what this test pins:+///+///   * example violations live in the `cases` table, which already carries all+///     five sentences as must-catch rows, and in `README.md`'s changelog — both+///     are records ABOUT the rule, read by people;+///   * `SKILL.md` is instructions TO the reader, so nothing in it is exempt:+///     scan it whole, quotes included. If a sentence there needs to describe a+///     forbidden instruction, it can name the rule instead of quoting it —+///     which is what every other line of that file already does.+///+/// Per-file policy, not per-syntax: strip quoted categories in `README.md` and+/// `docs/`, strip nothing in `SKILL.md`.+#[test]+fn a_violation_cannot_hide_inside_markup_in_the_file_an_agent_follows() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let pin = std::fs::read_to_string(root.join("tests/wiring.rs")).expect("wiring.rs");+    let body = pin+        .split("fn nothing_asks_the_user_to_close_documents")+        .nth(1)+        .expect("the pin exists")+        .to_string();++    // Does the stripping distinguish the file that INSTRUCTS from the files that+    // DESCRIBE? A per-file exemption, however spelled, mentions SKILL.md near+    // the stripping.+    let strips = body.contains("strip") || body.contains("quoted") || body.contains("blockquote");+    let per_file = body.contains("SKILL.md") && {+        let near = body+            .split("SKILL.md")+            .nth(1)+            .map(|s| s.chars().take(400).collect::<String>())+            .unwrap_or_default();+        near.contains("strip") || near.contains("quote") || near.contains("verbatim")+            || near.contains("whole")+    };++    assert!(+        !strips || per_file,+        "the pin strips blockquotes and inline code spans from every markdown file, SKILL.md \+         included — measured: a blockquoted \"ask the user to close the documents by hand\" and a \+         backticked one both produce ZERO offenders there, while the same sentence unquoted is \+         caught. README.md's quotes are a changelog describing the rule; SKILL.md's are read by \+         an agent as instructions, because markdown emphasis is presentational and a model \+         following that file does not stop at a blockquote. The 0.8.6 fix was right for one file \+         and has blinded the pin in the other. Split it per FILE, not per syntax: strip in \+         README.md and docs/, strip nothing in SKILL.md — example violations already live in the \+         `cases` table, which carries all five sentences. (strips: {strips}, per-file: {per_file})"+    );+}++/// THE MISSING PIN FOR `acknowledge_only`, WRITTEN OUT.+///+/// I reported this as a surviving mutation in pass 33 and it survives again in+/// pass 37: changing `named.len() == 1` to `named.len() >= 1` makes a dialog+/// whose buttons are `["OK", "Cancel"]` report `acknowledgeOnly: true` — "the+/// only thing on offer is to acknowledge this" said about a dialog with a real+/// choice in it — and 396 tests do not notice. The code is CORRECT; it is+/// unguarded, which is why this test passes rather than fails.+///+/// It matters because `acknowledgeOnly` is the field a human reads before+/// deciding whether to press something, on dialogs this bridge will never press+/// itself. The live License Manager dialog is genuinely OK-only; the next one+/// may not be.+#[test]+fn acknowledge_only_means_exactly_one_button_and_it_says_ok() {+    use swx_bridge::launchos::DialogContents;+    let d = |b: &[&str]| DialogContents {+        text: vec!["…".into()],+        buttons: b.iter().map(|s| s.to_string()).collect(),+        ..Default::default()+    };++    assert!(d(&["OK"]).acknowledge_only(), "one OK button is acknowledge-only");+    assert!(d(&["&OK"]).acknowledge_only(), "an accelerator ampersand is not part of the name");+    assert!(+        !d(&["OK", "Cancel"]).acknowledge_only(),+        "OK + Cancel offers a CHOICE — reporting it as acknowledge-only tells a human the dialog \+         cannot be answered wrongly, about a dialog that can"+    );+    assert!(+        !d(&["Save", "Don't Save", "Cancel"]).acknowledge_only(),+        "a save prompt is never acknowledge-only"+    );+    // 0.8.7 gave DialogContents its own `checkboxes` field, so a "don't show+    // again" no longer masquerades as a button — the pass-33 note about the+    // reported control list is addressed. An OK-only dialog that also carries a+    // checkbox is therefore still acknowledge-only, which is now the honest+    // answer rather than the accidentally-safe one.+    assert!(+        d(&["OK"]).acknowledge_only(),+        "with checkboxes held separately, OK-plus-a-checkbox is genuinely acknowledge-only"+    );+    assert!(!d(&[]).acknowledge_only(), "no buttons is not acknowledge-only");+    // `Close` alone is acknowledge-only too, and rightly — plenty of+    // informational dialogs offer only that. I asserted the opposite first and+    // the code corrected me.+    assert!(d(&["Close"]).acknowledge_only(), "a lone Close is also only an acknowledgement");+    assert!(+        !d(&["Close", "Cancel"]).acknowledge_only(),+        "two buttons are a choice however they are named"+    );+}
tests/adversary36.rsadded+170
@@ -0,0 +1,170 @@+//! ADVERSARIAL REVIEW OF 0.8.8 — pass thirty-eight, the convergence pass.+//!+//! Master is green at 5f2a9df: 400 pass, 0 fail on a clean checkout, verified+//! first.+//!+//! (2) THE REFUSED-SWEEP FIXTURE PINS THE MANIFEST, NOT JUST THE REPLY, and it+//!     does it properly: `a_refused_document_is_recorded_as_refused_in_the_manifest`+//!     reads `manifest.json` off disk and asserts `verified == false`, a+//!     `verdictReason` of real length, and `closed == false` per entry — then+//!     follows through to `swx_recover_list` and asserts the listing carries+//!     `verified:false` and never says "rescue". One gap, small: it does not+//!     assert `source == "manifest"` on those entries, so a refusal that+//!     arrived from the `reverified` path instead would satisfy it. The+//!     neighbouring test asserts `source` for the preserved case; adding the+//!     same line here costs nothing.++mod common;+use common::*;+use serde_json::json;+use std::path::Path;++fn s(v: &serde_json::Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++/// SEVERITY 1, CONSENT — "FOLLOWED" IS A SHORTER LIST THAN THE FILES AN AGENT+/// FOLLOWS.+///+/// MEASURED, planting the same sentence in two places and counting offenders:+///+/// ```text+///                                    unquoted   blockquoted+/// docs/RUNBOOK.md                       1            0+/// skills/swx-drive/SKILL.md             0            0+/// ```+///+/// Two different holes with one cause.+///+/// `docs/` strips quotes, which is right for `QUIT-INVESTIGATION.md` and the+/// build logs — they describe. It is wrong for a runbook, and `docs/` is exactly+/// where a runbook would go. The per-file policy asks "which file is this?" and+/// answers by directory, but the property that matters is whether the prose+/// INSTRUCTS or DESCRIBES, and both kinds live in `docs/`.+///+/// `skills/**` is not scanned AT ALL — the unquoted sentence there produces zero+/// offenders, so this is not stripping, it is absence. The pin walks `src/`,+/// `tools/`, `docs/` plus two root files. `skills/<name>/SKILL.md` is the+/// standard layout for sub-skills in this organisation, and a skillpack is+/// precisely a set of files written to be loaded and followed. Today the+/// directory does not exist, so this is latent; the day it does, the most+/// followed files in the repository are outside the rule that governs them.+///+/// THE LIST, and the shape I would argue for: invert the default. Scan+/// everything, strip nothing, and carry an explicit allowlist of files that+/// DESCRIBE — `README.md` and the named `docs/*.md` reports. A file added later+/// then defaults to being governed rather than silently exempt, which is the+/// direction this project chooses everywhere else it has a default: unknown kind+/// takes the strictest floor, an unreadable census is not permission, a document+/// of unknown ownership is not closed. The one place a new unknown currently+/// defaults to "allowed" is the rule about asking Drew to close his documents.+#[test]+fn every_file_an_agent_may_follow_is_governed_by_the_rule() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let pin = std::fs::read_to_string(root.join("tests/wiring.rs")).expect("wiring.rs");+    let body = pin+        .split("fn nothing_asks_the_user_to_close_documents")+        .nth(1)+        .expect("the pin exists")+        .chars()+        .take(4000)+        .collect::<String>();++    // Is the walk rooted at the repository, or at a hand-listed set of folders?+    let walks_everything = body.contains("stack = vec![root.to_path_buf()]")+        || body.contains("read_dir(root)")+        || body.contains("walk(root)");+    // Is the exemption an allowlist of files that DESCRIBE, rather than a+    // directory rule?+    let describes_allowlist = body.contains("DESCRIBES")+        || body.contains("describes")+        || body.contains("allowlist");+    let scans_skills = body.contains("skills");++    assert!(+        (walks_everything || scans_skills) && describes_allowlist,+        "the pin walks src/, tools/, docs/ and two root files. Measured: a blockquoted ask in \+         docs/RUNBOOK.md produces 0 offenders (stripped, though a runbook INSTRUCTS), and the \+         same sentence UNQUOTED in skills/swx-drive/SKILL.md produces 0 offenders (not scanned at \+         all). skills/<name>/SKILL.md is the standard sub-skill layout here and a skillpack is by \+         definition written to be followed. Invert the default: scan everything, strip nothing, \+         and allowlist the files that DESCRIBE — README.md and the named docs reports. Then a new \+         file defaults to governed, which is the direction every other default in this project \+         takes. (walks everything: {walks_everything}, scans skills: {scans_skills}, describes \+         allowlist: {describes_allowlist})"+    );+}++/// SEVERITY 2 — THE ONE SURVIVING MUTANT: `hostError` IS PINNED BY PRESENCE, NOT+/// BY CONTENT.+///+/// Full mutation table over every non-`cfg(windows)` guard added since 0.7.8,+/// re-run this pass:+///+/// ```text+/// acknowledge_only   len()==1 -> >=1                    CAUGHT (2)+/// write_manifest     verified -> always true            CAUGHT (1)+/// recoveryBytes      Changed -> Same                    CAUGHT (2)+/// recoveryBytes      Unbindable -> Same (trusted)       CAUGHT (1)+/// unknown-kind recovery floor -> 0                      CAUGHT (2)+/// estimate floor     max -> min                         CAUGHT (3)+/// import_diagnostics -> unknown                         CAUGHT (1)+/// reverified_entry   unknown -> true                    CAUGHT (2)+/// host_failure_entry drops e.data                       *** SURVIVES ***+/// ```+///+/// Eight of nine now bite, including the two that survived pass 37 — the+/// refused-document fixture earned its keep. The ninth is the fix for my own+/// pass-30 finding, and the reason it survives is visible in its test:+///+/// ```ignore+/// // THE FIELD ITSELF must exist — it can be null because the host sent+/// // nothing, but it must not be ABSENT because nothing constructs it.+/// check!(b, "...and carries the structured failure field", e.get("hostError").is_some(), …);+/// ```+///+/// That reasoning is right about the fixture it has: `mock-com-timeout` produces+/// a `SidecarError` with no `data`, so `hostError` is legitimately null and the+/// only thing the test CAN assert is presence. But `Value::Null` is present, so+/// replacing `e.data.clone()` with `Value::Null` — throwing the structure away+/// entirely — passes. The guard cannot distinguish "the host sent nothing" from+/// "we discarded what it sent", which is the exact distinction it exists to make.+///+/// What is missing is a fault, not an assertion: a sweep-path failure that+/// carries structured COM data, the way `mock-import-load4-throws` does for the+/// import. This test fails today because no fixture in the suite produces a+/// non-null `hostError` from a sweep — so the data path has never once been+/// exercised end to end.+#[test]+fn a_sweep_failure_carrying_com_detail_delivers_it() {+    let scratch = TempDir::new("adv36-hostdata");+    std::fs::write(scratch.join("mock-com-timeout"), "").unwrap();+    let machine = TempDir::new("adv36-hostdata-machine");+    std::fs::write(machine.join("windows.json"), "[]").unwrap();+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let failed = r["closeFailed"].as_array().cloned().unwrap_or_default();+    assert!(!failed.is_empty(), "precondition: the host failed the sweep: {}", s(&r));+    let e = &failed[0];+    assert!(+        e.get("hostError").is_some(),+        "precondition: the field is constructed (this much IS pinned): {}",+        s(e)+    );++    assert!(+        !e["hostError"].is_null(),+        "no fixture in this suite produces a sweep failure carrying structured COM detail, so \+         `hostError` is null in every test that touches it — and Value::Null satisfies the \+         `.is_some()` pin. Replacing `e.data.clone()` with `Value::Null` in host_failure_entry \+         passes all 400 tests: the guard cannot tell \"the host sent nothing\" from \"we threw \+         away what it sent\", which is the distinction it was built for. The gap is a FAULT, not \+         an assertion — mock-import-load4-throws already shows the shape for the import path. \+         Entry: {}",+        s(e)+    );+}
tests/common/mod.rs+70−4
@@ -105,6 +105,47 @@ impl Bridge {     /// worked) is read back by a DIFFERENT server process. A test that reboots     /// into a fresh temp dir proves the opposite of what it claims to.     pub fn start_in(scratch: TempDir, extra_env: &[(&str, &str)]) -> Bridge {+        // THE PORT RACE, AND WHY THIS LOOPS.+        //+        // `free_port()` binds a listener, reads the port the OS assigned, and+        // CLOSES it — so between that close and the child's bind, another test+        // binary in the parallel suite can be handed the same port and take it.+        // The loser prints "port already in use — exiting cleanly" and dies,+        // which is the BRIDGE BEHAVING CORRECTLY: it refuses to steal a port+        // somebody else owns. The defect is in this harness's allocation, not in+        // the product, and it made+        // `the_type_mismatch_that_named_the_argument_reaches_the_caller` fail+        // about one run in five under the full suite.+        //+        // So a collision on THAT EXACT CAUSE takes the next port. Every other+        // startup failure still panics with the log, because a harness that+        // retries everything hides real breakage.+        // OWNERSHIP OF THE SCRATCH DIR TRAVELS WITH EACH ATTEMPT and comes back+        // on failure. Handing the inner call a BORROWED view instead left the+        // owned `TempDir` to drop at the end of this function — which DELETES the+        // directory the bridge is using. Three launch tests caught that+        // immediately, which is the system working.+        let mut scratch = scratch;+        for attempt in 0..8 {+            match Self::start_on_a_free_port(scratch, extra_env) {+                Ok(b) => return b,+                Err((returned, log)) if log.contains("already in use") && attempt < 7 => {+                    scratch = returned;+                    continue;+                }+                Err((_, log)) => panic!(+                    "the bridge did not come up on attempt {}: {log}",+                    attempt + 1+                ),+            }+        }+        unreachable!("the loop above either returns or panics")+    }++    fn start_on_a_free_port(+        scratch: TempDir,+        extra_env: &[(&str, &str)],+    ) -> Result<Bridge, (TempDir, String)> {         let port = free_port();         let log_dir = TempDir::new("log");         let log_path = log_dir.join("bridge.log");@@ -144,16 +185,41 @@ impl Bridge {         loop {             if let Ok(r) = httpc::get(&bridge.url("/status"), Duration::from_secs(2)) {                 if r.status == 200 {-                    return bridge;+                    return Ok(bridge);                 }             }-            if Instant::now() > deadline {-                panic!("server never came up. log:\n{}", bridge.log());+            // THE CHILD IS GONE. Say so from its log rather than waiting out the+            // deadline — the port collision exits in milliseconds, and the caller+            // decides whether that log means "try another port" or "fail".+            if bridge.is_gone() || Instant::now() > deadline {+                let mut bridge = bridge;+                let log = bridge.log();+                // Hand the directory back so the next attempt uses the SAME one:+                // the caller's test wrote its fixtures there. The failed Bridge is+                // then dropped holding only a BORROWED view, so its Drop kills the+                // child without deleting the directory.+                let scratch = bridge.take_scratch();+                return Err((scratch, log));             }-            std::thread::sleep(Duration::from_millis(100));+            std::thread::sleep(Duration::from_millis(50));         }     } +    /// Move the scratch directory OUT, leaving a borrowed view behind — so this+    /// Bridge's `Drop` stops owning it and the caller can hand it to another+    /// attempt.+    fn take_scratch(&mut self) -> TempDir {+        let p = self.scratch.0.clone();+        std::mem::replace(&mut self.scratch, TempDir::borrowed(&p))+    }++    /// Has the child gone? Non-blocking. `try_wait` needs `&mut`, and the child+    /// is behind an immutable borrow here, so this asks the OS about the pid —+    /// which is also how the rest of this project answers liveness questions.+    fn is_gone(&self) -> bool {+        !std::path::Path::new(&format!("/proc/{}", self.child.id())).exists()+    }+     pub fn url(&self, path: &str) -> String {         format!("http://127.0.0.1:{}{path}", self.port)     }
tests/data_root.rsadded+822
@@ -0,0 +1,822 @@+//! SEV-1, MEASURED 2026-09-30: WHERE THE BRIDGE'S DATA LIVES.+//!+//! Until 0.9.0 every file this bridge wrote on its own initiative — the recovery+//! folders that hold the ONLY copy of work the user had not saved, the action+//! ledger, the ownership registry — lived in `bridges-cache\solidworks`, the+//! directory ab INSTALLS the bridge into. ab's updater re-extracts that+//! directory, and on Drew's machine it was found CREATED 2026-09-18 09:42 by the+//! auto-install of 0.8.9: every recovery folder from 09-17 was gone. Only test+//! documents were in them. The next time it would not have been.+//!+//! Every test here drives the real binary with the two locations laid out the+//! way ab lays them out under `%LOCALAPPDATA%`, and asserts one of the four+//! things the fix promises:+//!+//!   * the data root is NOT the install cache, and nothing is written there;+//!   * what an older bridge left in the install cache is COPIED out, verified+//!     byte for byte, never over anything, with the originals left in place —+//!     each file once;+//!   * `swx_recover_list` still reads the install cache, and says which of its+//!     rescues is the only copy;+//!   * a sweep REFUSES to place recovery under an install location, however the+//!     path is spelled — and closes nothing when it refuses.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++fn s(v: &Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++/// A machine whose window census can be read, so the sweep is allowed to close.+fn a_visible_machine(tag: &str) -> TempDir {+    let d = TempDir::new(tag);+    std::fs::write(d.join("windows.json"), "[]").unwrap();+    d+}++/// `%LOCALAPPDATA%`, with ab's layout under it.+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 Bridge").join("solidworks-data")+    }+    fn cache(&self) -> PathBuf {+        self.local.0.join("Adom Bridge").join("bridges-cache")+    }+    fn legacy(&self) -> PathBuf {+        self.cache().join("solidworks")+    }+}++/// Unsaved work the sweep has to write somewhere: a pathless import and a saved+/// document with changes.+fn a_session_with_unsaved_work(root: &Path) {+    std::fs::create_dir_all(root).unwrap();+    std::fs::write(+        root.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 1, "active": "untitled:a",+            "docs": [+                {"docType": "part", "path": "untitled:a", "title": "Imported.step.SLDPRT",+                 "saved": false, "dirty": true},+                {"docType": "part", "path": "C:\\Users\\drew\\work\\bracket.SLDPRT",+                 "title": "bracket.SLDPRT", "saved": true, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+}++/// A file that looks like what SOLIDWORKS writes: four varying bytes, the format+/// marker at offset 4, and a body.+fn a_document(len: usize) -> Vec<u8> {+    let mut v = vec![0x9f, 0xe4, 0x18, 0x9f, 0x00, 0x00, 0x00, 0x04];+    v.resize(len, b'x');+    v+}++fn write(path: &Path, bytes: &[u8]) {+    std::fs::create_dir_all(path.parent().unwrap()).unwrap();+    std::fs::write(path, bytes).unwrap();+}++/// A sweep folder the way a pre-0.9.0 bridge left it: the rescue and its record.+fn a_legacy_sweep(recovery: &Path, sweep: &str, name: &str, bytes: &[u8]) -> PathBuf {+    a_legacy_sweep_of(recovery, sweep, &[(name, bytes)])+}++/// ...with several rescues in it.+fn a_legacy_sweep_of(recovery: &Path, sweep: &str, rescues: &[(&str, &[u8])]) -> PathBuf {+    let dir = recovery.join(sweep);+    let mut entries: Vec<Value> = Vec::new();+    for (name, bytes) in rescues {+        write(&dir.join(name), bytes);+        entries.push(json!({+            "name": name,+            "recoveryPath": dir.join(name).to_string_lossy(),+            "title": name, "originalPath": Value::Null, "kind": "part",+            "namingFallback": Value::Null, "originalBytes": Value::Null,+            "recoveryBytes": bytes.len(), "signatureOk": true,+            "verified": true, "verdictReason": Value::Null, "preserved": true,+            "closed": true, "wasDirty": true, "wasPathless": true+        }));+    }+    write(+        &dir.join("manifest.json"),+        s(&json!({+            "manifestVersion": 1,+            "sweep": sweep,+            "reason": "the user asked me to close SOLIDWORKS",+            "failures": [],+            "files": entries+        }))+        .as_bytes(),+    );+    dir+}++fn sweeps_named<'a>(list: &'a Value, sweep: &str) -> Vec<&'a Value> {+    list["sweeps"]+        .as_array()+        .map(|a| a.iter().filter(|x| x["sweep"] == sweep).collect())+        .unwrap_or_default()+}++fn ledger_count(root: &Path, action: &str) -> usize {+    std::fs::read_to_string(root.join("ledger.jsonl"))+        .unwrap_or_default()+        .lines()+        .filter(|l| l.contains(&format!("\"action\":\"{action}\"")))+        .count()+}++// ─────────────────────────────────────────────────────────────────────────────++#[test]+fn recovery_is_written_to_the_data_root_never_into_the_install_cache() {+    let lay = Layout::new("dataroot-default");+    a_session_with_unsaved_work(&lay.data());+    let machine = a_visible_machine("dataroot-default-machine");+    let local = lay.local.path();+    // NO OVERRIDE: the root comes from %LOCALAPPDATA%, as it does on Drew's+    // machine. (An empty SWX_SCRATCH_DIR is the harness's way of not setting it.)+    let b = Bridge::start_with(&[+        ("SWX_SCRATCH_DIR", ""),+        ("LOCALAPPDATA", &local),+        ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+    ]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close SOLIDWORKS"}));+    check!(b, "swx_close_all_safely: the sweep runs in the default layout", r["success"] == true && r["done"] == true, s(&r));++    let dir = PathBuf::from(r["recoveryDir"].as_str().unwrap_or(""));+    check!(+        b,+        "swx_close_all_safely: the rescue is written under the DATA root",+        dir.starts_with(lay.data().join("recovery")),+        format!("{} vs {}", dir.display(), lay.data().display())+    );+    check!(+        b,+        "swx_close_all_safely: ...and NOT under ab's install cache, which an update empties",+        !dir.starts_with(lay.cache()),+        dir.display().to_string()+    );+    let written: Vec<PathBuf> = r["recovered"]+        .as_array()+        .cloned()+        .unwrap_or_default()+        .iter()+        .filter_map(|x| x["recoveryPath"].as_str().map(PathBuf::from))+        .collect();+    check!(b, "swx_close_all_safely: both unsaved documents were written", written.len() == 2, s(&r));+    for f in &written {+        check!(+            b,+            "swx_close_all_safely: every rescue exists, in the data root",+            f.exists() && f.starts_with(lay.data()),+            f.display().to_string()+        );+    }+    // THE REST OF THE DATA WENT WITH IT.+    check!(+        b,+        "swx_close_all_safely: the ledger is in the data root",+        lay.data().join("ledger.jsonl").exists(),+        lay.data().display().to_string()+    );+    check!(+        b,+        "swx_close_all_safely: and nothing at all was written into the install cache",+        !lay.cache().exists(),+        lay.cache().display().to_string()+    );++    let l = b.call("swx_recover_list", json!({}));+    check!(+        b,+        "swx_recover_list: reads the data root",+        l["recoveryRoot"] == json!(lay.data().join("recovery").to_string_lossy()),+        s(&l)+    );+    check!(+        b,+        "swx_recover_list: ...and names the legacy location it also reads",+        l["legacyRecoveryRoot"] == json!(lay.legacy().join("recovery").to_string_lossy()),+        s(&l)+    );+    let sweep = l["sweeps"].as_array().and_then(|a| a.first()).cloned().unwrap_or(json!({}));+    check!(+        b,+        "swx_recover_list: a sweep in the data root is not reported at risk",+        sweep["location"] == "data" && sweep["atRiskFromUpdate"] == false && sweep["warning"].is_null(),+        s(&sweep)+    );+}++#[test]+fn what_an_older_bridge_left_in_the_install_cache_is_copied_out_verified_and_left_in_place() {+    let lay = Layout::new("dataroot-migrate");+    let legacy = lay.legacy();+    let rescue = a_document(64 * 1024);+    let stuck_bytes = a_document(32 * 1024);+    let sweep_dir = a_legacy_sweep_of(+        &legacy.join("recovery"),+        "20260917-101500",+        &[("bracket.SLDPRT", &rescue), ("stuck.SLDPRT", &stuck_bytes)],+    );+    let manifest_bytes = std::fs::read(sweep_dir.join("manifest.json")).unwrap();+    let ledger = b"{\"at\":1789000000,\"action\":\"document_created\",\"detail\":{}}\n{\"at\":1789000100,\"action\":\"close_all_safely\",\"detail\":{}}\n".to_vec();+    write(&legacy.join("ledger.jsonl"), &ledger);+    let timings = br#"{"swx_docs":{"count":3,"secs":[0.1,0.2,0.3]}}"#.to_vec();+    write(&legacy.join("timings.json"), &timings);+    let scratch_doc = a_document(20 * 1024);+    write(&legacy.join("exports").join("scratch").join("Part1.SLDPRT"), &scratch_doc);+    write(&legacy.join("owned-docs.json"), br#"{"documents":[]}"#);+    // WHAT IS NOT DATA: the bridge's own install. Never copied.+    write(&legacy.join("swx-bridge.exe"), b"MZ not really");+    write(&legacy.join("bridge.json"), br#"{"name":"solidworks"}"#);+    // A FILE THAT IS ALREADY IN THE DATA ROOT is never overwritten.+    write(&legacy.join("settings.json"), br#"{"badge":false}"#);+    write(&lay.data().join("settings.json"), br#"{"badge":true}"#);+    // AND ONE THAT CANNOT BE READ: its copy fails, and must leave nothing behind.+    let stuck = legacy.join("recovery").join("20260917-101500").join("stuck.SLDPRT");+    let unreadable = {+        use std::os::unix::fs::PermissionsExt;+        std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o000)).unwrap();+        // Root reads anything; the failure half of this test needs a user that+        // cannot.+        std::fs::read(&stuck).is_err()+    };++    let local = lay.local.path();+    let env = [("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())];+    let b = Bridge::start_with(&env);+    // BEFORE ANY VERB: this is what the migration left, untouched by anything else.+    let data = lay.data();+    let same = |rel: &str, want: &[u8]| std::fs::read(data.join(rel)).map(|got| got == want).unwrap_or(false);+    check!(b, "migration: a rescue is copied byte for byte", same("recovery/20260917-101500/bracket.SLDPRT", &rescue), data.display().to_string());+    check!(b, "migration: ...with the sweep's own record", same("recovery/20260917-101500/manifest.json", &manifest_bytes), data.display().to_string());+    check!(b, "migration: the bridge's scratch documents come too", same("exports/scratch/Part1.SLDPRT", &scratch_doc), data.display().to_string());+    check!(b, "migration: and its timings", same("timings.json", &timings), data.display().to_string());+    check!(b, "migration: and the ownership registry", data.join("owned-docs.json").exists(), data.display().to_string());+    let migrated_ledger = std::fs::read(data.join("ledger.jsonl")).unwrap_or_default();+    check!(+        b,+        "migration: the ledger is carried over whole, and the move is recorded in it",+        migrated_ledger.starts_with(&ledger)+            && ledger_count(&data, "data_migrated_from_install_cache") == 1,+        String::from_utf8_lossy(&migrated_ledger).to_string()+    );+    check!(+        b,+        "migration: the bridge's own install is not data and is not copied",+        !data.join("swx-bridge.exe").exists() && !data.join("bridge.json").exists(),+        data.display().to_string()+    );+    check!(+        b,+        "migration: a file already in the data root is NEVER overwritten",+        same("settings.json", br#"{"badge":true}"#),+        data.display().to_string()+    );+    // THE ORIGINALS ARE LEFT EXACTLY WHERE THEY WERE.+    check!(+        b,+        "migration: every original is left in place, unchanged",+        std::fs::read(sweep_dir.join("bracket.SLDPRT")).ok().as_deref() == Some(&rescue[..])+            && std::fs::read(legacy.join("ledger.jsonl")).ok().as_deref() == Some(&ledger[..])+            && std::fs::read(legacy.join("exports/scratch/Part1.SLDPRT")).ok().as_deref() == Some(&scratch_doc[..])+            && std::fs::read(legacy.join("settings.json")).ok().as_deref() == Some(&br#"{"badge":false}"#[..]),+        legacy.display().to_string()+    );+    let marker: Value = serde_json::from_str(+        &std::fs::read_to_string(data.join("migrated-from-legacy.json")).unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    let listed = |key: &str, file: &str| {+        marker[key].as_array().map(|a| a.iter().any(|e| e["file"] == file)).unwrap_or(false)+    };+    check!(+        b,+        "migration: the record says what was copied, what was already there, and that the originals stayed",+        marker["originalsLeftInPlace"] == true+            && listed("copied", "recovery/20260917-101500/bracket.SLDPRT")+            && listed("skipped", "settings.json"),+        s(&marker)+    );+    if unreadable {+        check!(+            b,+            "migration: a copy that cannot be made is reported, and leaves NOTHING behind in the data root",+            listed("failed", "recovery/20260917-101500/stuck.SLDPRT")+                && !data.join("recovery/20260917-101500/stuck.SLDPRT").exists(),+            s(&marker)+        );+    }++    // THE LISTING, AFTER: the copy is the one to use, and the original says so.+    let l = b.call("swx_recover_list", json!({}));+    let both = sweeps_named(&l, "20260917-101500");+    let ours = both.iter().find(|x| x["location"] == "data").cloned().cloned().unwrap_or(json!({}));+    let theirs = both.iter().find(|x| x["location"] == "legacyInstallCache").cloned().cloned().unwrap_or(json!({}));+    let bracket = ours["files"]+        .as_array()+        .and_then(|a| a.iter().find(|f| f["name"] == "bracket.SLDPRT").cloned())+        .unwrap_or(json!({}));+    check!(+        b,+        "swx_recover_list: the migrated rescue is listed from the data root, with the sweep's own verdict",+        bracket["source"] == "manifest" && bracket["verified"] == true && ours["atRiskFromUpdate"] == false,+        s(&l)+    );+    check!(+        b,+        "swx_recover_list: the original is listed from the install cache, AT RISK",+        theirs["atRiskFromUpdate"] == true,+        s(&l)+    );+    if unreadable {+        // One of its files did not copy, so this is still the only copy of it.+        check!(+            b,+            "swx_recover_list: a legacy sweep with a file that did not copy claims NO copy",+            theirs["copiedTo"].is_null()+                && theirs["warning"].as_str().unwrap_or("").contains("NO copy"),+            s(&theirs)+        );+        // AND THE DATA-ROOT SIDE DOES NOT CALL IT GONE. Its manifest came across+        // and names the file; the file is still in the old location.+        let stuck_here = ours["files"]+            .as_array()+            .and_then(|a| a.iter().find(|f| f["name"] == "stuck.SLDPRT").cloned())+            .unwrap_or(json!({}));+        check!(+            b,+            "swx_recover_list: a file whose copy failed is NOT COPIED YET, pointing at its only copy — not GONE",+            stuck_here["notCopiedYet"] == true+                && stuck_here["present"] == false+                && stuck_here["legacyPath"] == json!(stuck.to_string_lossy())+                && stuck_here["howToOpen"].as_str().unwrap_or("").contains("NOT COPIED YET"),+            s(&ours)+        );+    }+    drop(b);++    // ── SECOND START. The user deleted one migrated copy; the unreadable file+    //    became readable; and an older bridge (a rollback) wrote a new rescue.+    std::fs::remove_file(data.join("exports/scratch/Part1.SLDPRT")).unwrap();+    {+        use std::os::unix::fs::PermissionsExt;+        std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o644)).unwrap();+    }+    let late = a_document(48 * 1024);+    a_legacy_sweep(&legacy.join("recovery"), "20260930-120000", "late.SLDPRT", &late);+    let b = Bridge::start_with(&env);+    check!(+        b,+        "migration: a rescue that appeared since is copied on the next start",+        same("recovery/20260930-120000/late.SLDPRT", &late),+        data.display().to_string()+    );+    check!(+        b,+        "migration: a copy the user deleted is NOT brought back — each file is migrated once",+        !data.join("exports/scratch/Part1.SLDPRT").exists(),+        data.display().to_string()+    );+    if unreadable {+        check!(+            b,+            "migration: a copy that failed is retried on the next start",+            data.join("recovery/20260917-101500/stuck.SLDPRT").exists(),+            data.display().to_string()+        );+        let l = b.call("swx_recover_list", json!({}));+        let theirs = sweeps_named(&l, "20260917-101500")+            .into_iter()+            .find(|x| x["location"] == "legacyInstallCache")+            .cloned()+            .unwrap_or(json!({}));+        let ours = sweeps_named(&l, "20260917-101500")+            .into_iter()+            .find(|x| x["location"] == "data")+            .cloned()+            .unwrap_or(json!({}));+        let stuck_here = ours["files"]+            .as_array()+            .and_then(|a| a.iter().find(|f| f["name"] == "stuck.SLDPRT").cloned())+            .unwrap_or(json!({}));+        check!(+            b,+            "swx_recover_list: ...and the retried copy is listed as a rescue in the data root",+            stuck_here["source"] == "manifest" && stuck_here["verified"] == true,+            s(&ours)+        );+        check!(+            b,+            "swx_recover_list: once every file has a copy, the original points at it",+            theirs["copiedTo"] == json!(data.join("recovery").join("20260917-101500").to_string_lossy())+                && theirs["warning"].as_str().unwrap_or("").contains("use that one"),+            s(&theirs)+        );+    }+    check!(+        b,+        "migration: the second run is on the record too",+        ledger_count(&data, "data_migrated_from_install_cache") == 2,+        std::fs::read_to_string(data.join("ledger.jsonl")).unwrap_or_default()+    );+    drop(b);++    // ── THIRD START, nothing new: nothing is copied and nothing is recorded.+    let b = Bridge::start_with(&env);+    b.call("swx_recover_list", json!({}));+    check!(+        b,+        "migration: a start with nothing new to copy does nothing",+        ledger_count(&data, "data_migrated_from_install_cache") == 2+            && !data.join("exports/scratch/Part1.SLDPRT").exists(),+        std::fs::read_to_string(data.join("ledger.jsonl")).unwrap_or_default()+    );+}++/// A sweep whose recovery folder would land under an install location — the+/// install directory itself, or ab's install cache — is refused before anything+/// is written or closed, however the path is spelled.+#[test]+fn a_sweep_refuses_to_place_recovery_under_an_install_location() {+    let t = TempDir::new("dataroot-refuse");+    let inst = t.join("inst");+    std::fs::create_dir_all(&inst).unwrap();+    std::os::unix::fs::symlink(&inst, t.join("link")).unwrap();+    std::fs::create_dir_all(t.join("elsewhere")).unwrap();+    let p = |x: &Path| x.to_string_lossy().to_string();+    let unrelated = p(&t.join("unrelated"));+    // (label, data root, install dir, ab's install cache)+    let cases: Vec<(&str, String, String, String)> = vec![+        ("inside the install directory", p(&inst.join("data")), p(&inst), unrelated.clone()),+        ("through a link to it", p(&t.join("link").join("data")), p(&inst), unrelated.clone()),+        ("spelled with ..", format!("{}/../inst/data", p(&t.join("elsewhere"))), p(&inst), unrelated.clone()),+        ("spelled in another case", p(&inst.join("data")).replace("/inst/", "/INST/"), p(&inst), unrelated.clone()),+        ("AS the install directory", p(&t.join("eq")), p(&t.join("eq").join("recovery")), unrelated.clone()),+        ("inside ab's install cache", p(&t.join("cache").join("data")), unrelated.clone(), p(&t.join("cache"))),+    ];+    for (label, root, install, cache) in cases {+        // The mock session lives in the data root; for the case-spelled variant,+        // at the path as the filesystem sees it.+        let seed = PathBuf::from(if label == "spelled in another case" { p(&inst.join("data")) } else { root.clone() });+        a_session_with_unsaved_work(&seed);+        let machine = a_visible_machine("dataroot-refuse-machine");+        let b = Bridge::start_with(&[+            ("SWX_SCRATCH_DIR", &root),+            ("SWX_INSTALL_DIR", &install),+            ("SWX_LEGACY_DIR", &cache),+            ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+        ]);+        b.call("swx_start", json!({}));+        let before = b.call("swx_docs", json!({}));+        let r = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close SOLIDWORKS"}));+        check!(+            b,+            &format!("swx_close_all_safely: refuses a recovery folder {label}"),+            r["success"] == false && r["errorCode"] == "recovery_inside_install_dir",+            s(&r)+        );+        let after = b.call("swx_docs", json!({}));+        check!(+            b,+            &format!("swx_close_all_safely: ...and closes NOTHING when it refuses ({label})"),+            before["count"].as_u64().is_some() && before["count"] == after["count"],+            format!("before {} after {}", s(&before), s(&after))+        );+        check!(+            b,+            &format!("swx_close_all_safely: ...and writes nothing ({label})"),+            !seed.join("recovery").exists(),+            seed.display().to_string()+        );+        check!(+            b,+            &format!("swx_close_all_safely: the bridge said so in its log at start ({label})"),+            b.log().contains("WARNING recovery is inside an install location"),+            b.log()+        );+    }++    // A SIBLING THAT MERELY SHARES A PREFIX is not inside anything.+    let sibling = p(&t.join("inst-data"));+    a_session_with_unsaved_work(Path::new(&sibling));+    let machine = a_visible_machine("dataroot-refuse-sibling");+    let b = Bridge::start_with(&[+        ("SWX_SCRATCH_DIR", &sibling),+        ("SWX_INSTALL_DIR", &p(&inst)),+        ("SWX_LEGACY_DIR", &unrelated),+        ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+    ]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close SOLIDWORKS"}));+    check!(+        b,+        "swx_close_all_safely: a data root beside the install directory is not refused",+        r["success"] == true && r["done"] == true,+        s(&r)+    );+}++/// A rescue that exists ONLY in the install cache — written by an older bridge+/// after this one started, say — is listed, and the listing says it is the only+/// copy and that the next update deletes it.+#[test]+fn recover_list_finds_a_rescue_left_only_in_the_install_cache() {+    let t = TempDir::new("dataroot-legacy-list");+    let data = t.join("data");+    let cache = t.join("cache");+    std::fs::create_dir_all(&data).unwrap();+    let b = Bridge::start_with(&[+        ("SWX_SCRATCH_DIR", &data.to_string_lossy()),+        ("SWX_LEGACY_DIR", &cache.to_string_lossy()),+    ]);+    let rescue = a_document(40 * 1024);+    a_legacy_sweep(&cache.join("recovery"), "20260929-080000", "gear.SLDPRT", &rescue);++    let l = b.call("swx_recover_list", json!({}));+    let sweep = sweeps_named(&l, "20260929-080000").first().cloned().cloned().unwrap_or(json!({}));+    check!(+        b,+        "swx_recover_list: a rescue left in the install cache is listed",+        sweep["location"] == "legacyInstallCache",+        s(&l)+    );+    check!(+        b,+        "swx_recover_list: ...marked at risk from the next update, as the ONLY copy",+        sweep["atRiskFromUpdate"] == true+            && sweep["copiedTo"].is_null()+            && sweep["warning"].as_str().unwrap_or("").contains("NO copy"),+        s(&sweep)+    );+    let gear = sweep["files"]+        .as_array()+        .and_then(|a| a.iter().find(|f| f["name"] == "gear.SLDPRT").cloned())+        .unwrap_or(json!({}));+    check!(+        b,+        "swx_recover_list: ...and its verdict is read from its own record, as anywhere else",+        gear["source"] == "manifest" && gear["verified"] == true && gear["bytes"] == rescue.len(),+        s(&gear)+    );+}++/// A COPY THAT DOES NOT MATCH ITS ORIGINAL IS NOT A COPY. It is removed — a+/// half-copied rescue must never sit in the data root looking like a whole one —+/// the failure is on the record, the original stays where it was, the listing+/// says the original is the only copy, and the next start tries again.+#[test]+fn a_copy_that_does_not_match_its_original_is_discarded_and_retried() {+    let lay = Layout::new("dataroot-corrupt");+    let rescue = a_document(64 * 1024);+    let sweep_dir = a_legacy_sweep(&lay.legacy().join("recovery"), "20260917-090000", "shaft.SLDPRT", &rescue);+    let data = lay.data();+    std::fs::create_dir_all(&data).unwrap();+    std::fs::write(data.join("mock-migrate-corrupts-copy"), "").unwrap();+    let local = lay.local.path();+    let env = [("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())];++    let b = Bridge::start_with(&env);+    let marker: Value = serde_json::from_str(+        &std::fs::read_to_string(data.join("migrated-from-legacy.json")).unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    let failed = |m: &Value, f: &str| m["failed"].as_array().map(|a| a.iter().any(|e| e["file"] == f)).unwrap_or(false);+    check!(+        b,+        "migration: a copy that does not verify is reported as failed",+        failed(&marker, "recovery/20260917-090000/shaft.SLDPRT"),+        s(&marker)+    );+    check!(+        b,+        "migration: ...and REMOVED, so no partial rescue sits in the data root",+        !data.join("recovery/20260917-090000/shaft.SLDPRT").exists(),+        data.display().to_string()+    );+    check!(+        b,+        "migration: ...and the original is untouched",+        std::fs::read(sweep_dir.join("shaft.SLDPRT")).ok().as_deref() == Some(&rescue[..]),+        sweep_dir.display().to_string()+    );+    let l = b.call("swx_recover_list", json!({}));+    check!(+        b,+        "migration: a sweep none of whose files copied leaves no empty folder to be listed beside it",+        sweeps_named(&l, "20260917-090000").len() == 1+            && !data.join("recovery").join("20260917-090000").exists(),+        s(&l)+    );+    let theirs = sweeps_named(&l, "20260917-090000")+        .into_iter()+        .find(|x| x["location"] == "legacyInstallCache")+        .cloned()+        .unwrap_or(json!({}));+    check!(+        b,+        "swx_recover_list: the original is the only copy, and says so",+        theirs["atRiskFromUpdate"] == true+            && theirs["copiedTo"].is_null()+            && theirs["warning"].as_str().unwrap_or("").contains("NO copy"),+        s(&l)+    );+    drop(b);++    // THE DISK RECOVERS. The next start retries, and this time it lands.+    std::fs::remove_file(data.join("mock-migrate-corrupts-copy")).unwrap();+    let b = Bridge::start_with(&env);+    let copy = data.join("recovery/20260917-090000/shaft.SLDPRT");+    check!(+        b,+        "migration: a copy that failed is retried on the next start, and verified",+        std::fs::read(&copy).ok().as_deref() == Some(&rescue[..]),+        copy.display().to_string()+    );+    let marker: Value = serde_json::from_str(+        &std::fs::read_to_string(data.join("migrated-from-legacy.json")).unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    check!(+        b,+        "migration: ...and the record no longer lists it as failing",+        !failed(&marker, "recovery/20260917-090000/shaft.SLDPRT")+            && marker["copied"].as_array().map(|a| a.iter().any(|e| e["file"] == "recovery/20260917-090000/shaft.SLDPRT")).unwrap_or(false),+        s(&marker)+    );+}++/// THE MIGRATION RUNS BEFORE THE BRIDGE READS ITS OWN STATE.+///+/// `Ctx::new` restores the ownership registry from the data root. A migration+/// that ran after it would copy the registry into place and leave the running+/// bridge owning nothing until its NEXT restart — so the first bridge after an+/// update would refuse to close or write to every document it had created+/// before it, which is the update-shaped version of bug #5.+#[test]+fn ownership_an_older_bridge_recorded_is_restored_by_the_first_start_after_the_move() {+    let lay = Layout::new("dataroot-ownership");+    let legacy = lay.legacy();+    std::fs::create_dir_all(&legacy).unwrap();+    // AN OLDER BRIDGE, writing into the install cache, creates a document.+    {+        let old = Bridge::start_in(TempDir::borrowed(&legacy), &[]);+        old.call("swx_start", json!({}));+        let made = old.call("swx_new_document", json!({"type": "part", "keepUnsaved": true}));+        check!(old, "precondition: the older bridge created a document", made["success"] == true, s(&made));+        check!(+            old,+            "precondition: ...and recorded that it owns it, in the install cache",+            std::fs::read_to_string(legacy.join("owned-docs.json")).unwrap_or_default().contains("Part"),+            legacy.display().to_string()+        );+    }+    // SOLIDWORKS KEEPS RUNNING ACROSS THE UPDATE. The simulator keeps its session+    // in the bridge's root, so it is carried over by hand — that is the+    // simulator's plumbing, not the bridge's data, and not part of what is tested.+    let data = lay.data();+    std::fs::create_dir_all(&data).unwrap();+    for e in std::fs::read_dir(&legacy).unwrap().flatten() {+        let name = e.file_name().to_string_lossy().to_string();+        if name.starts_with("mock-") && e.path().is_file() {+            std::fs::copy(e.path(), data.join(&name)).unwrap();+        }+    }+    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "migration: the FIRST start after the move already owns what the older bridge created",+        b.log().contains("ownership registry restored") && st["success"] == true,+        b.log()+    );+}++/// A START THAT DIES MID-COPY leaves nothing under the real name.+///+/// The migration runs at start-up, and a start can die at any point — a crash, a+/// power cut, ab killing a bridge that is slow to answer `/status`. A copy made+/// straight to the real name would leave a TRUNCATED file there, and the+/// never-overwrite rule would then protect the truncated copy forever while the+/// next update deleted the only whole one. So every copy is made under a+/// temporary name and renamed into place once it verifies. And if the original+/// is gone by the time anyone looks, the half-copied bytes are listed as what+/// they are: incomplete, and possibly all that is left.+#[test]+fn a_start_that_dies_mid_copy_leaves_no_truncated_file_under_the_real_name() {+    let lay = Layout::new("dataroot-interrupted");+    let rescue = a_document(64 * 1024);+    // `zeta` sorts after `manifest.json`, and the start is let through ONE copy:+    // so it dies with the record already across and the rescue half-written —+    // the state in which a listing could call the rescue GONE.+    let legacy_sweep = a_legacy_sweep(&lay.legacy().join("recovery"), "20260917-080000", "zeta.SLDPRT", &rescue);+    let data = lay.data();+    std::fs::create_dir_all(&data).unwrap();+    std::fs::write(data.join("mock-migrate-dies-mid-copy"), "1").unwrap();+    let local = lay.local.path();++    // THE START THAT DIES. Spawned directly: the harness rightly treats a bridge+    // that never answers as a failure.+    let status = std::process::Command::new(common::BIN)+        .args(["--port", &common::free_port().to_string()])+        .env("SWX_MOCK", "1")+        .env("ADOM_BIND_HOST", "127.0.0.1")+        .env("SWX_SCRATCH_DIR", "")+        .env("LOCALAPPDATA", &local)+        .env_remove("ADOM_DIRECT_API_URL")+        .env_remove("ADOM_BRIDGE_TOKEN")+        .stdout(std::process::Stdio::null())+        .stderr(std::process::Stdio::null())+        .status()+        .expect("run the bridge");+    assert_eq!(status.code(), Some(3), "precondition: the start died mid-copy");+    let here = data.join("recovery").join("20260917-080000");+    let left: Vec<String> = std::fs::read_dir(&here)+        .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect())+        .unwrap_or_default();+    assert!(+        left.iter().any(|n| n == "manifest.json")+            && left.iter().any(|n| n == "zeta.SLDPRT.migrating")+            && !left.iter().any(|n| n == "zeta.SLDPRT"),+        "a start that died mid-copy must leave its half-written bytes ONLY under a temporary name, \+         never under the real one — the never-overwrite rule would protect a truncated rescue \+         forever: {left:?}"+    );++    // THE UPDATE ARRIVES before the bridge starts again, and takes the original.+    std::fs::remove_dir_all(&legacy_sweep).unwrap();+    std::fs::remove_file(data.join("mock-migrate-dies-mid-copy")).unwrap();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let l = b.call("swx_recover_list", json!({}));+    let ours = sweeps_named(&l, "20260917-080000").first().cloned().cloned().unwrap_or(json!({}));+    let files = ours["files"].as_array().cloned().unwrap_or_default();+    // ONE entry for the rescue: the partial copy, which explains itself. Not a+    // rescue, and not a second entry calling the same file GONE.+    check!(+        b,+        "swx_recover_list: the half-copied bytes are listed ONCE, as a PARTIAL COPY, never as a rescue",+        files.len() == 1+            && files[0]["partialCopy"] == true+            && files[0]["copyOf"] == "zeta.SLDPRT"+            && files[0]["verified"] == false+            && files[0]["howToOpen"].as_str().unwrap_or("").contains("PARTIAL COPY"),+        s(&l)+    );+    drop(b);++    // AND WHEN THE ORIGINAL IS STILL THERE, the next start simply copies it.+    let lay = Layout::new("dataroot-interrupted-retry");+    let legacy_sweep = a_legacy_sweep(&lay.legacy().join("recovery"), "20260917-081500", "gear.SLDPRT", &rescue);+    let data = lay.data();+    std::fs::create_dir_all(&data).unwrap();+    std::fs::write(data.join("mock-migrate-dies-mid-copy"), "").unwrap();+    let local = lay.local.path();+    let status = std::process::Command::new(common::BIN)+        .args(["--port", &common::free_port().to_string()])+        .env("SWX_MOCK", "1")+        .env("ADOM_BIND_HOST", "127.0.0.1")+        .env("SWX_SCRATCH_DIR", "")+        .env("LOCALAPPDATA", &local)+        .env_remove("ADOM_DIRECT_API_URL")+        .env_remove("ADOM_BRIDGE_TOKEN")+        .stdout(std::process::Stdio::null())+        .stderr(std::process::Stdio::null())+        .status()+        .expect("run the bridge");+    assert_eq!(status.code(), Some(3), "precondition: the start died mid-copy");+    std::fs::remove_file(data.join("mock-migrate-dies-mid-copy")).unwrap();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let here = data.join("recovery").join("20260917-081500");+    let names: Vec<String> = std::fs::read_dir(&here)+        .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect())+        .unwrap_or_default();+    check!(+        b,+        "migration: the next start copies the file whole and leaves no temporary behind",+        std::fs::read(here.join("gear.SLDPRT")).ok().as_deref() == Some(&rescue[..])+            && std::fs::read(here.join("manifest.json")).ok()+                == std::fs::read(legacy_sweep.join("manifest.json")).ok()+            && !names.iter().any(|n| n.ends_with(".migrating")),+        format!("{names:?}")+    );+}
tests/e2e.rs+64−1
@@ -20,7 +20,14 @@ fn s(v: &Value) -> String {  #[test] fn every_verb_over_http_against_the_real_binary() {-    let b = Bridge::start();+    // A MACHINE THIS PROCESS CAN LOOK AT. The safe-close sweep refuses to close+    // anything while the window census is unreadable — a dialog nobody can see is+    // the one that matters — so a walk that ends in a real close needs a session+    // the bridge can actually see. Without it this test would exercise the REFUSAL+    // and silently stop covering the close.+    let machine = TempDir::new("e2e-machine");+    std::fs::write(machine.join("windows.json"), "[]").unwrap();+    let b = Bridge::start_with(&[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);      // ── health endpoints ────────────────────────────────────────────────────     let (status_code, health) = b.get("/status");@@ -1167,6 +1174,11 @@ fn every_verb_over_http_against_the_real_binary() {         b.log()     ); +    // ── the safe-close family (Drew's standing authorization, 2026-09-11) ───+    let rec = b.call("swx_recover_list", json!({}));+    check!(b, "recover_list answers before anything has been preserved", rec["success"] == true, s(&rec));+    check!(b, "...with a recovery root", rec["recoveryRoot"].is_string(), s(&rec));+     // ── teardown verbs ──────────────────────────────────────────────────────     // The bridge closes its OWN documents freely; the user's need the opt-in     // (covered above), so the teardown closes one of ours.@@ -1222,6 +1234,31 @@ fn every_verb_over_http_against_the_real_binary() {         s(&r)     ); +    // THE SAFE CLOSE, on this same session — it is teardown, and it is the verb+    // Drew's standing authorization exists for: no opt-in, no confirmation, just+    // a reason, and every document written to disk before it closes.+    let swept = b.call(+        "swx_close_all_safely",+        json!({"reason": "the user asked me to close SOLIDWORKS"}),+    );+    check!(+        b,+        "close_all_safely preserves and closes in the DEFAULT posture",+        swept["success"] == true && swept["done"] == true,+        s(&swept)+    );+    check!(+        b,+        "...and every document it closed was on disk first",+        swept["recovered"]+            .as_array()+            .map(|a| a.iter().all(|d| d["preserved"] == true))+            .unwrap_or(false),+        s(&swept)+    );+    let empty = b.call("swx_docs", json!({}));+    check!(b, "...leaving the session empty", empty["count"] == 0, s(&empty));+     // uninstall removes ONLY the bridge's own exports subtree — never the cache     // root ab owns (which, on a real machine, holds the running exe).     let exports = std::path::Path::new(&b.scratch.path()).join("exports");@@ -1231,9 +1268,35 @@ fn every_verb_over_http_against_the_real_binary() {         exports.is_dir(),         exports.display().to_string()     );+    let recovery = std::path::Path::new(&b.scratch.path()).join("recovery");+    let rescues_before: Vec<std::path::PathBuf> = std::fs::read_dir(&recovery)+        .map(|d| d.flatten().map(|e| e.path()).collect())+        .unwrap_or_default();+    check!(+        b,+        "precondition: the sweep above left a recovery folder for uninstall to leave alone",+        !rescues_before.is_empty(),+        recovery.display().to_string()+    );     let r = b.call("swx_uninstall", json!({"cascade": true}));     check!(b, "uninstall", r["success"] == true, s(&r));     check!(b, "uninstall echoes the cascade flag", r["cascade"] == true, s(&r));+    // THE RESCUES SURVIVE UNINSTALL. A recovery file can be the only copy of+    // work the user had not saved; ab calls this verb on app uninstall, ungated.+    check!(+        b,+        "uninstall PRESERVES every recovery folder, and says so",+        rescues_before.iter().all(|p| p.exists()) && r["recoveryPreserved"] == true,+        s(&r)+    );+    // LAST: it ends the session, so nothing may follow it that needs one.+    let down = b.call("swx_shutdown", json!({"reason": "the user asked me to close SOLIDWORKS"}));+    check!(+        b,+        "swx_shutdown closes what is open and quits — one verb, no opt-in, only a reason",+        down["success"] == true,+        s(&down)+    );     check!(         b,         "uninstall removed the exports subtree",
tests/import_diagnosis.rsadded+137
@@ -0,0 +1,137 @@+//! WHEN AN IMPORT FAILS, THE TRANSLATOR'S OWN ANSWER MUST REACH THE CALLER.+//!+//! Live, 2026-09-17, on a bridge-launched session: `LoadFile4` began raising a+//! COM exception on EVERY STEP file — a real McMaster part and the gate's own+//! fixture alike — while native `OpenDoc6` opens on the same session worked and+//! the safe close worked. So it is translator state, and the only thing that+//! could say WHICH translator state is the HRESULT.+//!+//! What the caller actually saw was:+//!+//! ```text+//! error:  SOLIDWORKS could not import … (LoadFile4 opened nothing, errors=0;+//!         LoadFile2 refused)+//! detail: SOLIDWORKS rejected 'LoadFile4'+//! ```+//!+//! The HRESULT, the EXCEPINFO source and description and the failing argument+//! index were all already computed — `variant.rs` puts them on the inner error —+//! and the import kept only `e.message`. A failure diagnosed from prose that+//! omits the one number the translator returned is not diagnosed at all, and+//! `swx_com_eval` is read-only by design, so there is no way to probe around it+//! (and no reason to loosen it).++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()+}++#[test]+fn a_failed_import_carries_the_com_failure_the_caller_needs() {+    let scratch = TempDir::new("import-diagnosis");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-import-load4-throws"), "").unwrap();+    let step = scratch.join("F-W10.step");+    std::fs::write(&step, "ISO-10303-21;\n").unwrap();++    let r = b.call("swx_import", json!({"path": step.to_string_lossy()}));+    check!(b, "the import fails", r["success"] == false, s(&r));++    // THE PROSE IS STILL THERE — it is what a person reads first.+    check!(+        b,+        "it still says what happened in words",+        r["error"].as_str().unwrap_or("").contains("could not import"),+        s(&r)+    );+    // AND THE DETAIL NOW CARRIES THE INNER FAILURE, not just its first sentence.+    let detail = r["detail"].as_str().unwrap_or("");+    check!(+        b,+        "the detail carries the COM failure, not only 'SOLIDWORKS rejected LoadFile4'",+        detail.contains("0x") && detail.len() > "SOLIDWORKS rejected 'LoadFile4'".len(),+        s(&r)+    );++    // AND THE STRUCTURED REMAINS REACH THE CALLER, so a translator can be+    // diagnosed by its HRESULT rather than by matching prose.+    let host = r["hostError"].clone();+    check!(b, "the structured failure survives the trip to the caller", host.is_object(), s(&r));+    let l4 = host["loadFile4"].clone();+    check!(b, "...with LoadFile4's own failure as a sub-object", l4.is_object(), s(&host));+    check!(+        b,+        "...carrying the HRESULT",+        l4["hresult"].as_str().unwrap_or("").starts_with("0x")+            || l4["detail"].as_str().unwrap_or("").contains("0x"),+        s(&l4)+    );+    check!(+        b,+        "...and LoadFile2's own answer, so 'refused' and 'threw' are distinguishable",+        host["loadFile2"]["returned"] == false,+        s(&host)+    );+    check!(b, "...and the path it was asked for", host["path"].is_string(), s(&host));+}++/// A SUCCESSFUL VERB CARRIES NO SUCH FIELD — the diagnosis rides on the failure,+/// not on every reply.+#[test]+fn a_working_import_reports_no_host_failure() {+    let scratch = TempDir::new("import-diagnosis-ok");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    let step = scratch.join("smoke.step");+    std::fs::write(&step, "ISO-10303-21;\n").unwrap();+    let r = b.call("swx_import", json!({"path": step.to_string_lossy()}));+    check!(b, "the import succeeds", r["success"] == true, s(&r));+    check!(b, "and carries no failure object", r["hostError"].is_null(), s(&r));+}++/// THE ACTUAL LIVE FAILURE, and what a caller can now do with it.+///+/// 2026-09-17: `hostError.loadFile4.detail = "Type mismatch. (0x80020005)+/// (argument index 1)"`. `rgvarg` is packed REVERSED, so argument index 1 is the+/// THIRD parameter of `LoadFile4(path, "r", importData, byref errors)` —+/// `importData` — which this bridge had passed as `VT_EMPTY` since v0.2.0. The+/// fix is one variant encoding; this test is about the other half, the half that+/// made the fix findable: a caller who can read the HRESULT and the argument+/// index can locate a bug in an API they cannot see the source of.+#[test]+fn the_type_mismatch_that_named_the_argument_reaches_the_caller() {+    let scratch = TempDir::new("import-typemismatch");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-import-typemismatch"), "").unwrap();+    let step = scratch.join("F-W10.step");+    std::fs::write(&step, "ISO-10303-21;\n").unwrap();++    let r = b.call("swx_import", json!({"path": step.to_string_lossy()}));+    check!(b, "the import fails", r["success"] == false, s(&r));+    let l4 = r["hostError"]["loadFile4"].clone();+    check!(+        b,+        "the caller is told WHICH failure it was, by number",+        l4["hresult"] == "0x80020005" || l4["detail"].as_str().unwrap_or("").contains("0x80020005"),+        s(&r)+    );+    check!(+        b,+        "...and WHICH argument SOLIDWORKS objected to",+        l4["argumentIndex"] == 1 || l4["detail"].as_str().unwrap_or("").contains("argument index 1"),+        s(&l4)+    );+    check!(+        b,+        "...and that LoadFile2 answered rather than threw, so the two are distinguishable",+        r["hostError"]["loadFile2"]["returned"] == false+            && r["hostError"]["loadFile2"]["failure"].is_null(),+        s(&r["hostError"])+    );+}
tests/interconnect.rsadded+441
@@ -0,0 +1,441 @@+//! **3D INTERCONNECT DECIDES HOW A NEUTRAL FILE COMES IN, AND IT IS THE USER'S+//! SETTING.**+//!+//! Measured live 2026-09-17, reading `GetUserPreferenceToggle` off the running+//! application with ids taken from the installed+//! `SolidWorks.Interop.swconst.dll` at+//! `C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\`:+//!+//! ```text+//! 691  swMultiCAD_Enable3DInterconnect        TRUE+//! 291  swImportAutoRunImportDiagnostics       TRUE+//! 690  swImportNeutralRunDiagnostics          TRUE+//! 639  swForceEnableImportDiagnosis           FALSE+//! ```+//!+//! With 691 ON — the default — SOLIDWORKS does not translate a STEP through+//! `LoadFile4`/`LoadFile2` at all. It OPENS it as a part holding a LINKED 3D+//! Interconnect feature. That is why `LoadFile4` executed cleanly once 0.7.9+//! fixed the variant encoding and still returned no document with `errors = 1`+//! (`swGenericError`).+//!+//! The rule this file mostly exists to hold: **the bridge reads that preference+//! and never writes it.** Not set, and not flipped-and-restored — a crash between+//! the halves would leave Drew's SOLIDWORKS configured differently than he left+//! it.++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_step(scratch: &TempDir, name: &str) -> String {+    let p = scratch.join(name);+    std::fs::write(&p, "ISO-10303-21;\n").unwrap();+    p.to_string_lossy().to_string()+}++/// WITH 3D INTERCONNECT ON, NOTHING IMPORTS — AND THE BRIDGE SAYS SO BY NAME.+///+/// The matrix of 2026-09-17, on a real STEP with the toggle TRUE:+///+/// ```text+/// LoadFile4, Nothing                     -> no document, errors=1+/// LoadFile4, GetImportFileData object    -> no document, errors=1+/// LoadFile2                              -> false+/// OpenDoc6 docType 1, options 0/1/2/16   -> no document, errors=2097152 (requires repair)+/// OpenDoc6 docType 0, options 1          -> no document, errors=1024 (invalid file type)+/// ```+///+/// So there is no programmatic neutral-file import route on this edition while+/// that setting is on, and the imports that worked from 09-05 to 09-11 must have+/// run with it off. `auto` still makes ONE `LoadFile4` attempt — a future edition+/// that accepts it will simply work — and then reports what is ACTUALLY wrong.+/// Reporting a translator failure instead would send the reader hunting for a+/// missing translator that is not the problem.+#[test]+fn an_import_blocked_by_the_setting_says_which_setting() {+    let scratch = TempDir::new("interconnect-blocked");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-interconnect-blocks-import"), "").unwrap();+    let before = b.call("swx_docs", json!({}));+    let step = a_step(&scratch, "F-W10.step");++    let r = b.call("swx_import", json!({"path": step}));+    check!(b, "the import fails", r["success"] == false, s(&r));+    check!(+        b,+        "...with a code that names the cause, not a translator",+        r["errorCode"] == "interconnect_blocks_import",+        s(&r)+    );+    check!(+        b,+        "...telling the user exactly where the setting lives",+        r["detail"].as_str().unwrap_or("").contains("Enable 3D Interconnect"),+        s(&r)+    );+    check!(+        b,+        "...and that the bridge will not change it for them",+        r["detail"].as_str().unwrap_or("").contains("DOES NOT CHANGE USER SETTINGS"),+        s(&r)+    );+    // NOTHING WAS OPENED AND NOTHING WAS CHANGED.+    let after = b.call("swx_docs", json!({}));+    check!(b, "the session is untouched", after["count"] == before["count"], s(&after));+}++/// A DOCUMENT OPENED BY ANY ROUTE IS STILL OURS TO IDENTIFY AND DRAIN. The route+/// changed twice in two releases; ownership at birth did not, and the drain gate+/// depends on it.+#[test]+fn a_document_opened_by_any_import_route_is_still_ours() {+    let scratch = TempDir::new("interconnect-ownership");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    let step = a_step(&scratch, "F-W10.step");++    // The OpenDoc6 route, asked for by name — a measurement route now, but one+    // that still opens a document, and anything this bridge opens it must own.+    let r = b.call("swx_import", json!({"path": step, "route": "opendoc6"}));+    check!(b, "the named route ran", r["via"] == "OpenDoc6 (3D Interconnect)", s(&r));+    let identity = r["documentIdentity"].clone();+    check!(b, "the document is identified as it is born", identity["identity"] != "unknown", s(&r));++    let machine = TempDir::new("interconnect-ownership-machine");+    std::fs::write(machine.join("windows.json"), "[]").unwrap();+    let swept = b.call("swx_close_all_safely", json!({"reason": "draining the import"}));+    check!(+        b,+        "and it is preserved and closed like any document this bridge made",+        swept["recovered"].as_array().map(|a| !a.is_empty()).unwrap_or(false),+        s(&swept)+    );+}++/// WITH IT OFF, the translator route is used — and `linked` is false, because+/// that import really is self-contained.+#[test]+fn the_translator_route_is_used_when_interconnect_is_off() {+    let scratch = TempDir::new("interconnect-off");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-3d-interconnect-off"), "").unwrap();+    let step = a_step(&scratch, "F-W10.step");++    let r = b.call("swx_import", json!({"path": step}));+    check!(b, "the import succeeds", r["success"] == true, s(&r));+    check!(b, "the setting is reported as off", r["interconnect"] == false, s(&r));+    check!(b, "...the translator route is named", r["via"] == "LoadFile4", s(&r));+    check!(b, "...and the result is not linked to anything", r["linked"] == false, s(&r));+}++/// THE PREFERENCE IS NEVER WRITTEN — not set, and not flipped-and-restored.+///+/// A crash between the flip and the restore would leave the user's SOLIDWORKS+/// configured differently than they left it, and a CAD setting silently altered+/// by a tool is the kind of harm this bridge exists not to do. So the import+/// obeys the setting rather than arranging the world to suit itself.+#[test]+fn the_import_never_changes_the_users_preference() {+    let scratch = TempDir::new("interconnect-readonly");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    let step = a_step(&scratch, "F-W10.step");+    let r = b.call("swx_import", json!({"path": step}));+    check!(b, "precondition: the setting is READ and reported", r["interconnect"] == true, s(&r));++    // The setting is what it was, and a second import agrees. If the bridge had+    // flipped it to get the import through — even flipping it back afterwards —+    // this is where a crash would have left the user's SOLIDWORKS changed.+    let again = b.call("swx_import", json!({"path": a_step(&scratch, "second.step")}));+    check!(b, "the setting is unchanged after an import", again["interconnect"] == true, s(&again));++    // AND NO VERB EXISTS TO CHANGE IT. A caller cannot ask the bridge to flip+    // 3D Interconnect, by any spelling — the preference belongs to the user.+    let catalog = b.call("swx_describe", json!({}));+    let text = s(&catalog).to_lowercase();+    check!(+        b,+        "no verb offers to set a user preference",+        !text.contains("setuserpreference") && !text.contains("set_user_preference"),+        "the verb catalog mentions setting a user preference".to_string()+    );+}++/// BREAKING THE LINK IS REFUSED UNTIL IT IS MEASURED — and the refusal is useful.+///+/// Dissolving a 3D Interconnect link WRITES to the user's document, and the call+/// that does it has not been verified on this install. This project already+/// shipped one confident claim about somebody else's format from memory (pass+/// 23's OLE signature, wrong for every modern SOLIDWORKS file); that one cost a+/// refusal, and this one would modify a document.+#[test]+fn dissolving_the_link_is_refused_rather_than_guessed() {+    let scratch = TempDir::new("interconnect-dissolve");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    let step = a_step(&scratch, "F-W10.step");++    let before = b.call("swx_docs", json!({}));+    let r = b.call("swx_import", json!({"path": step, "dissolve": true}));+    check!(b, "it refuses", r["success"] == false, s(&r));+    check!(b, "...with a code a caller can branch on", r["errorCode"] == "dissolve_not_measured", s(&r));+    check!(+        b,+        "...saying plainly that nothing was changed",+        r["error"].as_str().unwrap_or("").contains("nothing was changed"),+        s(&r)+    );+    check!(+        b,+        "...and what would have to be measured, and the safe way round it",+        r["detail"].as_str().unwrap_or("").contains("EFFECT"),+        s(&r)+    );+    // AND IT REALLY DID NOTHING.+    let after = b.call("swx_docs", json!({}));+    check!(+        b,+        "the refusal left the session exactly as it was",+        after["count"] == before["count"],+        s(&after)+    );+}++/// THE TRANSLATOR ROUTE UNDER 3D INTERCONNECT IS THE LIVE FAILURE, and the reply+/// now carries the toggle that explains it. Before 0.8.0 a caller saw+/// "LoadFile4 opened nothing, errors=1" with no way to know why.+#[test]+fn a_translator_import_under_interconnect_reports_the_toggle_that_explains_it() {+    let scratch = TempDir::new("interconnect-explains");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-import-translator-under-interconnect"), "").unwrap();+    let step = a_step(&scratch, "F-W10.step");++    let r = b.call("swx_import", json!({"path": step}));+    check!(b, "it fails", r["success"] == false, s(&r));+    check!(+        b,+        "and the reply carries the setting that explains the failure",+        r["hostError"]["interconnect"] == true,+        s(&r)+    );+    check!(+        b,+        "...and the generic error the translator returned",+        r["hostError"]["errors"] == 1,+        s(&r)+    );+}++/// A MODAL IMPORT DIAGNOSTICS DIALOG IS NAMED, NOT ANSWERED, AND NOT A HANG.+///+/// `swImportAutoRunImportDiagnostics` is TRUE on this install (measured+/// 2026-09-17), so a STEP with faulty faces raises the Import Diagnostics dialog+/// DURING the import and the COM call never returns. A caller told only "the com+/// host did not answer" would reasonably retry — into the same dialog. A caller+/// told a dialog is on screen shows it to the user instead.+///+/// It is never answered: the choice it offers (attempt to heal the faces, or not)+/// changes the geometry the user ends up with.+#[test]+fn an_import_waiting_behind_a_dialog_says_so_instead_of_looking_hung() {+    let machine = TempDir::new("interconnect-dialog-machine");+    std::fs::write(+        machine.join("dialog-open.json"),+        json!({"hwnd": 9300, "className": "#32770", "title": "Import Diagnostics",+               "visible": true})+            .to_string(),+    )+    .unwrap();+    let scratch = TempDir::new("interconnect-dialog");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[+            ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+            ("SWX_MOCK_DOC_LATENCY_MS", "0"),+        ],+    );+    b.call("swx_start", json!({}));+    // The import wedges, exactly as it does behind a modal dialog.+    std::fs::write(b.scratch.join("mock-import-timeout-after-open"), "").unwrap();+    let step = a_step(&scratch, "faulty.step");++    let r = b.call("swx_import", json!({"path": step}));+    check!(b, "the import does not claim success", r["success"] != true, s(&r));+    check!(b, "it names the dialog it is waiting behind", r["stoppedByDialog"].is_object(), s(&r));+    check!(+        b,+        "...identifying it as the Import Diagnostics prompt",+        s(&r).contains("Import Diagnostics"),+        s(&r)+    );+    check!(+        b,+        "...and says this bridge never answers one",+        r["_hint"].as_str().unwrap_or("").contains("NEVER answers"),+        s(&r)+    );+}++/// THE MEASUREMENT SURFACE: a named route, exact arguments, and the invocation+/// echoed back.+///+/// No route is known to work on this edition today. `LoadFile4` executes and+/// returns nothing (`errors=1`); `LoadFile2` returns false, still unexplained;+/// `OpenDoc6(path, swDocPART, 1, …)` returns nothing with `errors=2097152`+/// (`swFileRequiresRepairError`) on two real STEP files. PowerShell cannot be+/// used to try alternatives — SOLIDWORKS Connected does not expose the type info+/// its COM adapter needs, so every member call fails `TYPE_E_ELEMENTNOTFOUND`+/// (0x8002802B) — which makes raw IDispatch through this bridge the only+/// instrument that exists. So the bridge grew a measurement surface, inside the+/// verb that already owns and registers what it opens, rather than an escape+/// hatch that would not.+#[test]+fn every_import_echoes_the_exact_invocation_it_made() {+    let scratch = TempDir::new("interconnect-matrix");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    let step = a_step(&scratch, "F-W10.step");++    // OPENDOC6 with explicit options and document type.+    let r = b.call(+        "swx_import",+        json!({"path": step, "route": "opendoc6", "openOptions": 3, "docType": 2}),+    );+    check!(b, "the named route is taken", r["via"] == "OpenDoc6 (3D Interconnect)", s(&r));+    check!(b, "...and the call is echoed exactly", r["invocation"]["method"] == "OpenDoc6", s(&r));+    check!(+        b,+        "...including the arguments a matrix row has to state",+        r["invocation"]["args"]["options"] == 3 && r["invocation"]["args"]["docType"] == 2,+        s(&r["invocation"])+    );++    // LOADFILE4 with a different arg string and real import data requested.+    let r = b.call(+        "swx_import",+        json!({"path": a_step(&scratch, "b.step"), "route": "loadfile4",+               "argString": "", "withImportData": true}),+    );+    check!(b, "the translator route is taken by name", r["via"] == "LoadFile4", s(&r));+    check!(b, "...with the argument string used", r["invocation"]["args"]["argString"] == "", s(&r));+    check!(+        b,+        "...and says which importData was passed",+        r["invocation"]["args"]["importData"] == "GetImportFileData(path)",+        s(&r["invocation"])+    );++    // LOADFILE2 alone, so its answer is its own rather than a fallback's.+    let r = b.call("swx_import", json!({"path": a_step(&scratch, "c.step"), "route": "loadfile2"}));+    check!(b, "LoadFile2 can be asked for by name", r["invocation"]["method"] == "LoadFile2", s(&r));++    // AND A TYPO IS REFUSED, never quietly treated as `auto` — a run that+    // measures something other than what was asked is worse than no run.+    let r = b.call("swx_import", json!({"path": a_step(&scratch, "d.step"), "route": "opendoc"}));+    check!(+        b,+        "an unknown route is refused",+        r["success"] == false && r["errorCode"] == "unknown_import_route",+        s(&r)+    );+}++/// DISSOLVE IS REFUSED ON EVERY ROUTE, not just the one it was written next to.+///+/// The refusal used to live inside the interconnect branch, so asking for+/// `dissolve:true` while the toggle was OFF — or while it could not be READ — was+/// silently dropped: the caller asked for an unlinked import, got an import, and+/// was told nothing either way about the link they asked to break. A request this+/// bridge will not honour has to fail closed wherever it arrives.+#[test]+fn dissolve_is_refused_on_every_route() {+    for (label, faults) in [+        ("interconnect on", vec![]),+        ("interconnect off", vec!["mock-3d-interconnect-off"]),+        ("toggle unreadable", vec!["mock-interconnect-toggle-unreadable"]),+    ] {+        let scratch = TempDir::new("interconnect-dissolve-routes");+        let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+        b.call("swx_start", json!({}));+        for f in &faults {+            std::fs::write(b.scratch.join(f), "").unwrap();+        }+        let step = a_step(&scratch, "F-W10.step");+        let before = b.call("swx_docs", json!({}));++        let r = b.call("swx_import", json!({"path": step, "dissolve": true}));+        // A REFUSAL, not a mention. "the reply contains the word dissolve" is+        // satisfied by a hint that happens to name it while the import went+        // ahead — which is the exact failure being tested for.+        check!(+            b,+            "dissolve is REFUSED with its code",+            r["success"] == false && r["errorCode"] == "dissolve_not_measured",+            format!("[{label}] {}", s(&r))+        );+        // AND NOTHING WAS IMPORTED: failing closed means the caller does not get+        // a linked part they did not ask for.+        let after = b.call("swx_docs", json!({}));+        check!(+            b,+            "...and nothing was opened",+            after["count"] == before["count"],+            format!("[{label}] {}", s(&after))+        );+    }+}++/// AN UNREADABLE TOGGLE IS ITS OWN OUTCOME — not "off".+///+/// `null` means SOLIDWORKS would not answer; `false` means the user turned it+/// off. They take the same route and mean opposite things about how much to trust+/// a failure: with the setting ON, nothing imports on this edition, so a failure+/// under an unreadable toggle cannot be attributed to a missing translator.+#[test]+fn an_unreadable_toggle_is_reported_as_unknown_not_as_off() {+    let scratch = TempDir::new("interconnect-unreadable");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-interconnect-toggle-unreadable"), "").unwrap();+    let step = a_step(&scratch, "F-W10.step");++    let ok = b.call("swx_import", json!({"path": step}));+    check!(b, "the import still runs", ok["success"] == true, s(&ok));+    check!(b, "the setting is reported as UNKNOWN, not false", ok["interconnect"].is_null(), s(&ok));+    check!(+        b,+        "...and the reply says the value could not be read",+        ok["interconnectSettingReadable"] == false,+        s(&ok)+    );++    // AND A FAILURE UNDER AN UNREADABLE TOGGLE IS ITS OWN ERROR.+    let scratch2 = TempDir::new("interconnect-unreadable-fail");+    let b2 = Bridge::start_in(TempDir::borrowed(&scratch2.0), &[]);+    b2.call("swx_start", json!({}));+    std::fs::write(b2.scratch.join("mock-interconnect-toggle-unreadable"), "").unwrap();+    std::fs::write(b2.scratch.join("mock-interconnect-blocks-import"), "").unwrap();+    let r = b2.call("swx_import", json!({"path": a_step(&scratch2, "x.step")}));+    check!(+        b2,+        "a failure under an unreadable toggle is NOT reported as a translator problem",+        r["errorCode"] == "interconnect_setting_unreadable",+        s(&r)+    );+    check!(+        b2,+        "...and points at the setting to check",+        r["detail"].as_str().unwrap_or("").contains("Enable 3D"),+        s(&r)+    );+}
tests/shutdown.rsadded+2228
@@ -0,0 +1,2228 @@+//! DREW'S STANDING AUTHORIZATION, 2026-09-11:+//!+//!   "I despise every time you ask for help closing. You MUST find/build a way to+//!    safely close the docs/program."+//!+//! The authorization is to CLOSE. It is not an authorization to LOSE anything,+//! and the difference is the whole of this file. Every test here asserts the same+//! sequence from a different angle:+//!+//!   **nothing closes before it is on disk.**++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()+}++/// A session like the ones that have actually caused trouble: a document the+/// user saved and then edited, two pathless imports SHARING A TITLE (the state+/// that made every verb refuse with `ambiguous_target` live), an assembly, and a+/// component the assembly holds open.+/// A machine whose WINDOW CENSUS CAN BE READ. Without one the sweep correctly+/// preserves everything and closes nothing — a dialog nobody can see is the one+/// that matters — so every test about CLOSING has to supply a session the bridge+/// can look at.+fn a_visible_machine(tag: &str) -> TempDir {+    let d = TempDir::new(tag);+    std::fs::write(d.join("windows.json"), "[]").unwrap();+    d+}++fn a_difficult_session(scratch: &TempDir) {+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 3, "active": "untitled:a",+            "docs": [+                // The user's own file, WITH unsaved changes. Their file on disk+                // must not be touched; their changes must still survive.+                {"docType": "part", "path": "C:\\Users\\drew\\work\\bracket.SLDPRT",+                 "title": "bracket.SLDPRT", "saved": true, "dirty": true},+                // Two imports, no path, SAME TITLE.+                {"docType": "part", "path": "untitled:a", "title": "First.step.SLDPRT",+                 "saved": false, "dirty": true},+                {"docType": "part", "path": "untitled:b", "title": "First.step.SLDPRT",+                 "saved": false, "dirty": true},+                // An assembly and a component it holds open.+                {"docType": "assembly", "path": "C:\\Users\\drew\\work\\gearbox.SLDASM",+                 "title": "gearbox.SLDASM", "saved": true, "dirty": false},+                {"docType": "part", "path": "C:\\Users\\drew\\work\\shaft.SLDPRT",+                 "title": "shaft.SLDPRT", "saved": true, "dirty": false,+                 "heldOpenByAssembly": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+}++#[test]+fn every_document_is_on_disk_before_anything_closes() {+    let scratch = TempDir::new("shutdown-safe");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-safe-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    let before = b.call("swx_docs", json!({}));+    check!(b, "precondition: a full session", before["count"].as_u64().unwrap_or(0) >= 4, s(&before));++    // DEFAULT POSTURE. No write opt-in, no per-document confirmation: the user+    // has already said this may happen. Only a reason.+    let r = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close SOLIDWORKS"}));+    check!(b, "the sweep succeeds in the default posture", r["success"] == true, s(&r));+    check!(b, "...and finishes", r["done"] == true, s(&r));++    // THE SAFETY PROPERTY: every document that was closed is on disk.+    let recovered = r["recovered"].as_array().cloned().unwrap_or_default();+    check!(b, "every document was accounted for", recovered.len() >= 4, s(&r));+    for doc in &recovered {+        let preserved = doc["preserved"].as_bool().unwrap_or(false);+        check!(b, "...each one preserved", preserved, s(doc));+        // A document that was dirty or pathless has a NEW file, and that file+        // really exists.+        if doc["wasDirty"] == true || doc["wasPathless"] == true {+            let rp = doc["recoveryPath"].as_str().unwrap_or("");+            check!(+                b,+                "...and a dirty or pathless document was written to recovery",+                !rp.is_empty() && std::path::Path::new(rp).exists(),+                s(doc)+            );+        }+    }++    // THE USER'S OWN FILE WAS NOT WRITTEN OVER: their dirty document went to+    // recovery under a different path.+    let theirs = recovered+        .iter()+        .find(|d| d["title"] == "bracket.SLDPRT")+        .cloned()+        .unwrap_or(json!({}));+    check!(+        b,+        "the user's dirty file was copied to recovery, NOT saved over",+        theirs["recoveryPath"].as_str().unwrap_or("") != "C:\\Users\\drew\\work\\bracket.SLDPRT"+            && theirs["recoveryPath"].as_str().unwrap_or("").contains("recovery"),+        s(&theirs)+    );++    // TWO DOCUMENTS SHARING A TITLE each got their own file.+    let paths: Vec<String> = recovered+        .iter()+        .filter(|d| d["title"] == "First.step.SLDPRT")+        .map(|d| d["recoveryPath"].as_str().unwrap_or("").to_string())+        .collect();+    check!(+        b,+        "same-titled pathless documents each get a UNIQUE recovery file",+        paths.len() == 2 && paths[0] != paths[1] && !paths[0].is_empty(),+        format!("{paths:?}")+    );++    // AND THE COUNT REACHES ZERO — including the component the assembly held.+    let after = b.call("swx_docs", json!({}));+    check!(b, "the session is empty", after["count"] == 0, s(&after));+}++/// A document that could NOT be written is not closed. This is the case the whole+/// design exists to get right: closing it would discard the only copy.+#[test]+fn a_document_that_cannot_be_saved_is_not_closed() {+    let scratch = TempDir::new("shutdown-nosave");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-nosave-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-saveas-fails"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep does NOT report itself done", r["done"] != true, s(&r));+    let remaining = r["remaining"].as_array().cloned().unwrap_or_default();+    check!(b, "...and names what it could not preserve", !remaining.is_empty(), s(&r));+    check!(+        b,+        "...saying why in terms of the harm",+        remaining[0]["why"].as_str().unwrap_or("").contains("NOT closed"),+        s(&remaining[0])+    );+    // The dirty documents are STILL OPEN: unsaved work is never discarded.+    let after = b.call("swx_docs", json!({}));+    check!(b, "the unsaved documents are still open", after["count"].as_u64().unwrap_or(0) > 0, s(&after));+}++/// A dialog stops the sweep and is never answered. A save prompt should be+/// impossible here — everything is clean before anything closes — so one+/// appearing means a premise is wrong.+#[test]+fn a_dialog_stops_the_sweep_and_is_never_answered() {+    let machine = TempDir::new("shutdown-dialog-machine");+    std::fs::write(+        machine.join("dialog-open.json"),+        json!({"hwnd": 9001, "className": "#32770", "title": "Save Modified Documents",+               "visible": true})+            .to_string(),+    )+    .unwrap();+    let scratch = TempDir::new("shutdown-dialog");+    a_difficult_session(&scratch);+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(+        b,+        "the sweep STOPS on a dialog",+        r["success"] == false && r["errorCode"] == "stopped_by_dialog",+        s(&r)+    );+    check!(+        b,+        "...names it",+        s(&r).contains("Save Modified Documents"),+        s(&r)+    );+    check!(+        b,+        "...and says it was not answered",+        r["_hint"].as_str().unwrap_or("").contains("never answers"),+        s(&r)+    );+    // NOTHING was closed.+    let after = b.call("swx_docs", json!({}));+    check!(b, "nothing was closed", after["count"].as_u64().unwrap_or(0) >= 4, s(&after));+}++/// The whole intention in one verb — and the quit happens only because the sweep+/// really emptied the session.+#[test]+fn swx_shutdown_preserves_everything_then_quits() {+    let machine = TempDir::new("shutdown-machine");+    std::fs::write(machine.join("quit-script.json"), r#"{"a":"exited"}"#).unwrap();+    let scratch = TempDir::new("shutdown-full");+    a_difficult_session(&scratch);+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[+            ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+            ("SWX_QUIT_SETTLE_MS", "80"),+            ("SWX_QUIT_EXIT_WAIT_SECS", "3"),+        ],+    );+    b.call("swx_start", json!({}));++    // NO write opt-in and NO launch attribution: the user's authorization is what+    // makes this allowed, and this bridge did not launch the session.+    let r = b.call("swx_shutdown", json!({"reason": "the user asked me to close SOLIDWORKS"}));+    check!(b, "the shutdown succeeds", r["success"] == true, s(&r));+    check!(b, "...having preserved every document", r["recovered"].as_array().map(|a| a.len() >= 4).unwrap_or(false), s(&r));+    check!(b, "...emptied the session", r["done"] == true, s(&r));+    check!(b, "...and quit SOLIDWORKS", r["stopped"] == true, s(&r));++    // IDEMPOTENT: the last thing a caller does should be safe to repeat.+    let again = b.call("swx_shutdown", json!({"reason": "making sure"}));+    check!(+        b,+        "a second shutdown is a no-op, not a failure",+        again["success"] == true && again["alreadyStopped"] == true,+        s(&again)+    );+}++/// What was preserved can be found again — otherwise "nothing is lost" is a claim+/// nobody can act on.+#[test]+fn swx_recover_list_finds_what_was_preserved() {+    let scratch = TempDir::new("shutdown-recover");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-recover-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let swept = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "precondition: the sweep ran", swept["done"] == true, s(&swept));++    let r = b.call("swx_recover_list", json!({}));+    let sweeps = r["sweeps"].as_array().cloned().unwrap_or_default();+    check!(b, "the sweep is listed", !sweeps.is_empty(), s(&r));+    let files = sweeps[0]["files"].as_array().cloned().unwrap_or_default();+    check!(b, "...with the files it preserved", files.len() >= 3, s(&sweeps[0]));+    check!(+        b,+        "...each really on disk",+        files.iter().all(|f| std::path::Path::new(f["path"].as_str().unwrap_or("")).exists()),+        s(&files[0])+    );+}++/// A DIALOG NOBODY CAN SEE. If the window census cannot be read, this bridge+/// cannot say whether a save prompt is on screen — and the two halves of the+/// sweep answer that differently. Saving loses nothing, so the saves go ahead;+/// closing while blind can lose everything, so nothing closes. The failure mode+/// this pins is the project's oldest: an unanswerable question read as+/// permission.+#[test]+fn an_unreadable_dialog_census_preserves_but_closes_nothing() {+    let scratch = TempDir::new("shutdown-blind");+    a_difficult_session(&scratch);+    // NO fake machine: there is no session this process can look at, so the+    // census is UNKNOWN — not empty.+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    b.call("swx_start", json!({}));++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(+        b,+        "a census nobody can read stops the closes",+        r["success"] == false && r["errorCode"] == "dialog_census_unknown",+        s(&r)+    );+    check!(b, "...says so in one word", r["stoppedBy"] == "dialogCensusUnknown", s(&r));+    check!(b, "...and does not claim a check it never made", r["dialogCensusReadable"] == false, s(&r));+    check!(b, "...nor that it finished", r["done"] != true, s(&r));++    // THE SAVES WENT AHEAD — and really wrote files.+    let recovered = r["recovered"].as_array().cloned().unwrap_or_default();+    let written: Vec<&Value> = recovered+        .iter()+        .filter(|d| d["preserved"] == true && d["recoveryPath"].is_string())+        .collect();+    check!(b, "the work was preserved anyway", written.len() >= 3, s(&r));+    for d in &written {+        let rp = d["recoveryPath"].as_str().unwrap_or("");+        let bytes = d["recoveryBytes"].as_u64().unwrap_or(0);+        check!(+            b,+            "...each recovery file exists, holds a real document's worth of bytes, \+             and carries the OLE compound-document signature",+            std::path::Path::new(rp).exists()+                && bytes >= 16 * 1024+                && d["recoverySignatureOk"] == true,+            s(d)+        );+    }++    // AND NOTHING WAS CLOSED.+    check!(b, "no document was closed", r["closed"].as_u64().unwrap_or(99) == 0, s(&r));+    let after = b.call("swx_docs", json!({}));+    check!(b, "the session is untouched", after["count"].as_u64().unwrap_or(0) >= 4, s(&after));+}++/// THE USER'S OWN FILE IS NEVER WRITTEN OVER, byte for byte.+///+/// `SaveAs3(path, ver, options=1)` with `swSaveAsOptions_Silent` ALONE — no+/// `Copy` bit — RE-POINTS the document at the new file. That is deliberate: the+/// document must become the recovery file, so the close that follows closes a+/// document whose only content is already on disk. The cost of re-pointing is+/// that the user's original is left exactly as it was, untouched, which is what+/// this test reads back one byte at a time. The journal supplies the other half:+/// the path closed was the RECOVERY path, so the re-point really happened before+/// the close.+#[test]+fn the_users_original_file_is_untouched_and_the_document_re_points() {+    let scratch = TempDir::new("shutdown-original");+    // A REAL FILE ON DISK holding known bytes, so "unchanged" is checkable.+    let theirs = scratch.join("bracket.SLDPRT");+    let original: Vec<u8> = (0u16..2048).map(|i| (i % 251) as u8).collect();+    std::fs::write(&theirs, &original).unwrap();+    let their_path = theirs.to_string_lossy().to_string();+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 0, "active": their_path,+            "docs": [+                {"docType": "part", "path": their_path, "title": "bracket.SLDPRT",+                 "saved": true, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-original-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep finished", r["success"] == true && r["done"] == true, s(&r));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let theirs_rec = rec.iter().find(|d| d["title"] == "bracket.SLDPRT").cloned().unwrap_or(json!({}));+    let rp = theirs_rec["recoveryPath"].as_str().unwrap_or("").to_string();+    check!(b, "their document went to a DIFFERENT file", !rp.is_empty() && rp != their_path, s(&theirs_rec));++    // THE ORIGINAL BYTES, unchanged.+    let now = std::fs::read(&theirs).unwrap_or_default();+    check!(+        b,+        "the user's original file is byte-for-byte what it was",+        now == original,+        format!("{} bytes before, {} after", original.len(), now.len())+    );++    // THE RE-POINT: what was closed was the recovery file, not their original.+    let log = std::fs::read_to_string(scratch.join("mock-close-order.log")).unwrap_or_default();+    check!(+        b,+        "the CLOSE key was the recovery path — the document re-pointed",+        log.lines().any(|l| l == format!("closed\t{rp}")),+        log.clone()+    );+    check!(+        b,+        "...and their original path was never closed, because it was never the document any more",+        !log.lines().any(|l| l == format!("closed\t{their_path}")),+        log+    );+}++/// ORDERING, on the case that makes ordering matter: an assembly holding a DIRTY+/// component open. The component cannot close while its parent holds it, and its+/// unsaved changes exist only in memory — so the sweep must write it FIRST and+/// close the parent before the child. This asserts the order of events, not the+/// final state: a session can end with everything on disk and still have closed+/// something before it was written.+#[test]+fn no_component_closes_before_its_own_recovery_write() {+    let scratch = TempDir::new("shutdown-ordering");+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 0, "active": "C:\\Users\\drew\\work\\rig.SLDASM",+            "docs": [+                // The parent, itself dirty.+                {"docType": "assembly", "path": "C:\\Users\\drew\\work\\rig.SLDASM",+                 "title": "rig.SLDASM", "saved": true, "dirty": true},+                // TWO DIRTY COMPONENTS the assembly holds open. Their changes are+                // in memory only; closing the parent takes them with it.+                {"docType": "part", "path": "C:\\Users\\drew\\work\\arm.SLDPRT",+                 "title": "arm.SLDPRT", "saved": true, "dirty": true,+                 "heldOpenByAssembly": true},+                {"docType": "part", "path": "", "title": "lever.SLDPRT",+                 "saved": false, "dirty": true, "heldOpenByAssembly": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-ordering-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep finished", r["success"] == true && r["done"] == true, s(&r));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    check!(b, "all three documents were preserved", rec.len() == 3, s(&r));+    for d in &rec {+        check!(+            b,+            "...each with a real document on disk, signature and all",+            d["recoveryBytes"].as_u64().unwrap_or(0) >= 16 * 1024+                && d["recoverySignatureOk"] == true,+            s(d)+        );+    }++    // THE JOURNAL. Every close must come after every write it depended on.+    let log = std::fs::read_to_string(scratch.join("mock-close-order.log")).unwrap_or_default();+    let lines: Vec<(&str, &str)> = log+        .lines()+        .filter_map(|l| l.split_once('\t'))+        .collect();+    let first_close = lines.iter().position(|(e, _)| *e == "closed");+    let last_write = lines.iter().rposition(|(e, _)| *e == "preserved");+    check!(+        b,+        "EVERY recovery write happened before the FIRST close",+        matches!((first_close, last_write), (Some(c), Some(w)) if w < c),+        log.clone()+    );++    // Each component's OWN write precedes its OWN close.+    for d in &rec {+        let rp = d["recoveryPath"].as_str().unwrap_or("").to_string();+        let wrote = lines.iter().position(|(e, p)| *e == "preserved" && *p == rp);+        let closed = lines.iter().position(|(e, p)| *e == "closed" && *p == rp);+        check!(+            b,+            "...and this document was written before it was closed",+            matches!((wrote, closed), (Some(w), Some(c)) if w < c),+            format!("{}\n{log}", s(d))+        );+    }++    // THE PARENT GOES FIRST: a child closed before its parent would have been+    // refused by SOLIDWORKS, which is the reason the order is what it is.+    let closes: Vec<&str> = lines.iter().filter(|(e, _)| *e == "closed").map(|(_, p)| *p).collect();+    let asm = closes.iter().position(|p| p.ends_with(".SLDASM"));+    check!(+        b,+        "the assembly closed before its components",+        asm == Some(0) && closes.len() == 3,+        format!("{closes:?}")+    );+    // AND NO CLOSE WAS EVER REFUSED. This is the assertion that makes the order+    // matter rather than the outcome: closing a child first still finishes, because+    // the sweep retries and the parent goes on the next pass — but SOLIDWORKS had+    // to say no first, and being told no is the thing we are avoiding.+    check!(+        b,+        "no close was attempted that SOLIDWORKS would refuse",+        !log.lines().any(|l| l.starts_with("refused\t")),+        log+    );+    let after = b.call("swx_docs", json!({}));+    check!(b, "the session is empty", after["count"] == 0, s(&after));+}++/// A RECOVERY FILE THAT HOLDS NOTHING IS NOT A SAVE. `Path::exists()` answers yes+/// to the zero-byte file a full disk leaves behind, and "the file is there" was+/// the whole premise for closing the document. So the write is verified by SIZE,+/// through the same constant on both backends, and a short file leaves the+/// document OPEN.+#[test]+fn a_zero_byte_recovery_file_does_not_authorise_a_close() {+    let scratch = TempDir::new("shutdown-partial");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-partial-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    // The disk fills: SaveAs3 "succeeds", the file appears, and it is empty.+    std::fs::write(b.scratch.join("mock-recovery-partial-write"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep does not report itself done", r["done"] != true, s(&r));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let short: Vec<&Value> = rec.iter().filter(|d| d["preserved"] == false).collect();+    check!(b, "the documents it could not preserve are named", short.len() >= 3, s(&r));+    for d in &short {+        // THE FILE IS THERE. Existence would have said yes.+        let rp = d["recoveryPath"].as_str().unwrap_or("");+        check!(b, "...the empty file really exists", std::path::Path::new(rp).exists(), s(d));+        check!(b, "...and the size is reported, not just a verdict", d["recoveryBytes"] == 0, s(d));+        check!(+            b,+            "...saying why in terms of the harm",+            d["why"].as_str().unwrap_or("").contains("NOT closed"),+            s(d)+        );+    }+    // NOTHING that was only in memory was closed.+    let after = b.call("swx_docs", json!({}));+    check!(+        b,+        "every document whose only copy was in memory is still open",+        after["count"].as_u64().unwrap_or(0) >= 3,+        s(&after)+    );+}++/// A TRUNCATED FILE IS NOT A SAVE — and this is the case the 1 KB floor of 0.7.1+/// admitted. A partial write on a full disk does not usually stop at zero bytes;+/// it writes a genuine header and then stops, at 2 KB or 8 KB or 30 KB. Every one+/// of those passed the old floor, was recorded `preserved:true`, and the document+/// was closed against it.+#[test]+fn a_truncated_recovery_file_does_not_authorise_a_close() {+    let scratch = TempDir::new("shutdown-truncated");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-truncated-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    // A real header, and then the disk fills.+    std::fs::write(b.scratch.join("mock-recovery-truncated"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep does not report itself done", r["done"] != true, s(&r));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let short: Vec<&Value> = rec.iter().filter(|d| d["preserved"] == false).collect();+    check!(b, "the truncated documents are named", short.len() >= 3, s(&r));+    for d in &short {+        // THE SIGNATURE IS RIGHT. Only the size is wrong — which is exactly why+        // the size check has to survive alongside the structural one.+        check!(b, "...the header was genuine", d["recoverySignatureOk"] == true, s(d));+        let bytes = d["recoveryBytes"].as_u64().unwrap_or(0);+        check!(b, "...the file was NOT empty", bytes > 0 && bytes < 16 * 1024, s(d));+        check!(+            b,+            "...and it says the file was short, in terms of the harm",+            d["why"].as_str().unwrap_or("").contains("NOT closed"),+            s(d)+        );+    }+    let after = b.call("swx_docs", json!({}));+    check!(+        b,+        "every document whose only copy was in memory is still open",+        after["count"].as_u64().unwrap_or(0) >= 3,+        s(&after)+    );+}++/// A FILE THAT IS NOT A SOLIDWORKS DOCUMENT DOES NOT AUTHORISE A CLOSE, whatever+/// its size. SOLIDWORKS documents are OLE compound files: eight bytes at offset 0+/// settle it structurally, where a byte count can only guess. The check FAILS+/// CLOSED, so a future container change would make this bridge refuse to close+/// rather than close against files it cannot vouch for.+#[test]+fn a_recovery_file_with_the_wrong_signature_does_not_authorise_a_close() {+    let scratch = TempDir::new("shutdown-header");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-header-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    // Full size, wrong container.+    std::fs::write(b.scratch.join("mock-recovery-bad-header"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep does not report itself done", r["done"] != true, s(&r));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let bad: Vec<&Value> = rec.iter().filter(|d| d["preserved"] == false).collect();+    check!(b, "the unrecognised files are named", bad.len() >= 3, s(&r));+    for d in &bad {+        check!(b, "...the signature is reported, not just a verdict", d["recoverySignatureOk"] == false, s(d));+        // SIZE ALONE WOULD HAVE PASSED IT.+        check!(+            b,+            "...and the file was big enough that a byte floor would have said yes",+            d["recoveryBytes"].as_u64().unwrap_or(0) >= 16 * 1024,+            s(d)+        );+        check!(+            b,+            "...saying what it found and what it expected",+            d["why"].as_str().unwrap_or("").contains("D0 CF 11 E0"),+            s(d)+        );+    }+    let after = b.call("swx_docs", json!({}));+    check!(b, "nothing was closed against a file we cannot vouch for",+           after["count"].as_u64().unwrap_or(0) >= 3, s(&after));+}++/// A DIALOG RAISED BY A SAVE STOPS THE CLOSES.+///+/// This is the whole reason the census moved. A save is not a silent operation:+/// it can raise a missing-external-reference or rebuild prompt of its own. 0.7.1+/// censused once per BATCH, so a dialog raised by the first save was invisible+/// until every other document in the batch had already been closed — the sweep+/// carried on working behind a modal window it had itself produced.+#[test]+fn a_dialog_raised_by_a_save_stops_the_closes() {+    let scratch = TempDir::new("shutdown-midsweep");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-midsweep-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let before = b.call("swx_docs", json!({}));+    let n = before["count"].as_u64().unwrap_or(0);+    check!(b, "precondition: a full session", n >= 4, s(&before));+    // The FIRST save puts a prompt on screen.+    std::fs::write(b.scratch.join("mock-dialog-after-save"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(+        b,+        "the sweep STOPS on a dialog a SAVE raised",+        r["success"] == false && r["errorCode"] == "stopped_by_dialog",+        s(&r)+    );+    check!(b, "...and names it", s(&r).contains("Save external references?"), s(&r));+    check!(b, "...and never answered it", r["_hint"].as_str().unwrap_or("").contains("never answers"), s(&r));++    // THE SWEEP STOPPED SAVING TOO, and this is the half only a per-SAVE census+    // can protect. Exactly one document was written — the one whose save raised+    // the prompt — and the bridge did not go on working behind a modal window it+    // had itself produced. A census that runs once per pass would have saved+    // every document in the pass before looking again.+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "it stopped at the document whose save raised the dialog",+        rec.len() == 1,+        s(&r)+    );++    // AND NOTHING WAS CLOSED.+    check!(b, "no document was closed", r["closed"].as_u64().unwrap_or(99) == 0, s(&r));+    let after = b.call("swx_docs", json!({}));+    check!(+        b,+        "the session still holds every document it held",+        after["count"].as_u64().unwrap_or(0) == n,+        s(&after)+    );+}++/// AND A DIALOG RAISED BY A CLOSE STOPS THE REST OF THEM. The census runs before+/// EVERY close, not once before the run of them: whatever a close puts on screen+/// is seen before the next one is attempted, and the documents that had not been+/// reached yet are left open with their work already safe on disk.+#[test]+fn a_dialog_raised_by_a_close_stops_the_remaining_closes() {+    let scratch = TempDir::new("shutdown-midclose");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-midclose-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let before = b.call("swx_docs", json!({}));+    let n = before["count"].as_u64().unwrap_or(0);+    check!(b, "precondition: a full session", n >= 4, s(&before));+    std::fs::write(b.scratch.join("mock-dialog-during-close"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(+        b,+        "the sweep STOPS on a dialog a CLOSE raised",+        r["success"] == false && r["errorCode"] == "stopped_by_dialog",+        s(&r)+    );+    check!(b, "...and names it", s(&r).contains("Rebuild before closing?"), s(&r));++    // EVERYTHING WAS PRESERVED FIRST — the stop costs the user nothing.+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "every document was preserved before any of this",+        rec.len() >= 4 && rec.iter().all(|d| d["preserved"] == true),+        s(&r)+    );+    // AND THE SWEEP STOPPED PART-WAY, which is the point: it did not run the+    // whole list and look afterwards.+    let closed = r["closed"].as_u64().unwrap_or(0);+    check!(b, "it closed something, but not everything", closed >= 1 && closed < n, s(&r));+    let after = b.call("swx_docs", json!({}));+    check!(+        b,+        "the documents it had not reached are still open",+        after["count"].as_u64().unwrap_or(0) > 0,+        s(&after)+    );+}++/// REAL SOLIDWORKS FILES ARE ACCEPTED. This is the test the 0.7.2 spec needed and+/// did not have.+///+/// Pass 23 asked for the recovery file to be verified against the OLE+/// compound-document signature `D0 CF 11 E0 A1 B1 1A E1` — the answer every+/// reference gives for "what is a SLDPRT". Measured against 166 real+/// `.SLDPRT`/`.SLDASM`/`.SLDDRW` files from three hardware repositories on this+/// machine, **none** of them begins with it, and it does not appear anywhere in+/// the first 4 MB of any of them. All 166 carry `00 00 00 04` at offset 4+/// instead. Shipping the signature as specified would have rejected every+/// document on a modern install: failing closed, so nothing lost — and the sweep+/// would have preserved everything and closed nothing, for ever.+///+/// The headers below are the first bytes of real files, copied verbatim. A check+/// on a format this bridge does not own has to be pinned to samples of the real+/// thing, or the next well-sourced guess ships too.+#[test]+fn the_container_check_accepts_real_solidworks_documents() {+    use swx_bridge::comhost::host::recovery_file_verdict;+    use swx_bridge::disk::DocKind;+    // The KIND is what the backend established, which for these fixtures is the+    // kind the file name states. It is passed in, never read back off the name+    // inside the verdict — that circularity is what 0.7.5 removed.+    let kind_of = |name: &str| DocKind::from_extension(name);+    let dir = TempDir::new("containers");+    // (name, first 16 bytes as they appear on disk, what it is)+    let real: &[(&str, [u8; 16])] = &[+        ("drawing.SLDDRW",+         [0x63,0xf7,0xbf,0x10, 0x00,0x00,0x00,0x04, 0x10,0x75,0x2c,0xc4, 0x74,0xe9,0x14,0x00]),+        ("part.SLDPRT",+         [0x72,0x61,0xbe,0x5c, 0x00,0x00,0x00,0x04, 0x8a,0x8a,0x3f,0xf4, 0x8c,0x14,0x00,0x06]),+        ("part2.SLDPRT",+         [0xb8,0x79,0xb5,0xb6, 0x00,0x00,0x00,0x04, 0x8c,0x0b,0x87,0x9d, 0x6c,0xbd,0xba,0x63]),+        ("assembly.SLDASM",+         [0xbe,0x00,0x56,0x2f, 0x00,0x00,0x00,0x04, 0x10,0x45,0x18,0x78, 0x3f,0xe6,0xef,0x0a]),+        ("assembly2.SLDASM",+         [0x45,0x01,0x95,0xb3, 0x00,0x00,0x00,0x04, 0x89,0x56,0xde,0xec, 0x80,0x79,0xa5,0x4c]),+    ];+    for (name, head) in real {+        let p = dir.join(name);+        let mut body = head.to_vec();+        // EACH KIND AT A PLAUSIBLE SIZE FOR ITS KIND. The floors differ because+        // the real files do.+        body.resize(+            if name.ends_with("SLDASM") {+                128 * 1024+            } else if name.ends_with("SLDDRW") {+                512 * 1024+            } else {+                64 * 1024+            },+            b'x',+        );+        std::fs::write(&p, &body).unwrap();+        let v = recovery_file_verdict(&p.to_string_lossy(), kind_of(name), None);+        assert!(+            v.ok && v.signature_ok && v.container == Some("swdoc"),+            "a REAL SOLIDWORKS file was refused: {name} -> ok={} container={:?} why={:?}\n\+             Every document on a modern install looks like this. A check that rejects it \+             fails closed, which is safe — and leaves the bridge unable to close anything, \+             which is the whole feature.",+            v.ok, v.container, v.why+        );+    }++    // The LEGACY container is still a document: an older file from an archive.+    let old = dir.join("legacy.SLDPRT");+    let mut body = vec![0xD0u8, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];+    body.resize(64 * 1024, b'x');+    std::fs::write(&old, &body).unwrap();+    let v = recovery_file_verdict(&old.to_string_lossy(), Some(DocKind::Part), None);+    assert!(v.ok && v.container == Some("ole-compound"), "legacy compound file refused: {v:?}");++    // AND THE THINGS THAT ARE NOT DOCUMENTS ARE STILL REFUSED — the check has to+    // keep discriminating, not just say yes.+    let junk = dir.join("junk.SLDPRT");+    std::fs::write(&junk, vec![b'x'; 64 * 1024]).unwrap();+    let v = recovery_file_verdict(&junk.to_string_lossy(), Some(DocKind::Part), None);+    assert!(!v.ok && !v.signature_ok, "a file that is not a document was accepted: {v:?}");++    let short = dir.join("short.SLDPRT");+    let mut body = real[0].1.to_vec();+    body.resize(2048, b'x');+    std::fs::write(&short, &body).unwrap();+    let v = recovery_file_verdict(&short.to_string_lossy(), Some(DocKind::Part), None);+    assert!(+        !v.ok && v.signature_ok && v.bytes == 2048,+        "a truncated document was accepted: {v:?}"+    );++    let missing =+        recovery_file_verdict(&dir.join("nothing-here.SLDPRT").to_string_lossy(), Some(DocKind::Part), None);+    assert!(!missing.ok, "a file that does not exist was accepted: {missing:?}");++    // THE FLOOR IS PER KIND, and this is what one floor for all three would have+    // cost: 32 KB is a plausible part and a badly truncated assembly. The+    // smallest real assembly measured here is 95,492 bytes — three times the+    // smallest part — so an assembly that stops at 32 KB has lost most of itself+    // while still carrying the marker and clearing any part-sized floor.+    for (name, ok_expected) in [("thirty-two.SLDPRT", true), ("thirty-two.SLDASM", false)] {+        let p = dir.join(name);+        let mut body = real[1].1.to_vec();+        body.resize(32 * 1024, b'x');+        std::fs::write(&p, &body).unwrap();+        let v = recovery_file_verdict(&p.to_string_lossy(), kind_of(name), None);+        assert_eq!(+            v.ok, ok_expected,+            "a 32 KB {name} was judged ok={} — the floors must differ by kind, or an assembly \+             is closed against a third of itself: {v:?}",+            v.ok+        );+    }+}++/// An older document really can be closed: the legacy container is accepted end+/// to end, not just by the verdict function.+#[test]+fn a_legacy_compound_document_is_preserved_and_closed() {+    let scratch = TempDir::new("shutdown-legacy");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-legacy-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-recovery-legacy-container"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep finished", r["success"] == true && r["done"] == true, s(&r));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "every document was preserved and vouched for",+        rec.iter().all(|d| d["preserved"] == true),+        s(&r)+    );+    let after = b.call("swx_docs", json!({}));+    check!(b, "the session is empty", after["count"] == 0, s(&after));+}++/// A SWEEP THAT CANNOT FIT DOES NOT START.+///+/// This is the answer to the failure marker-and-floor cannot see: THE RIGHT FILE,+/// CUT SHORT. The container marker is at offset 4, so every prefix of a real+/// document carries it, and a truncation past 16 KB clears the size floor too —+/// a 20 KB prefix of a 500 KB assembly passes both checks and the document is+/// closed against it. Nothing in the file would show it. So the defence is the+/// decision to begin: a truncation this bridge could not detect afterwards is one+/// it declines to cause.+#[test]+fn a_sweep_that_cannot_fit_refuses_to_start() {+    let scratch = TempDir::new("shutdown-nospace");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-nospace-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[+            ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+            // ROOM FOR ONE DOCUMENT, BUT NOT FOR THE SESSION. This is the case+            // only the gate before the sweep can catch: every individual save+            // would pass a per-save check and the sweep would get several+            // documents in before discovering it cannot finish — having already+            // written files it may not be able to complete.+            ("SWX_TEST_FREE_BYTES", "3000000"),+        ],+    );+    b.call("swx_start", json!({}));+    let before = b.call("swx_docs", json!({}));+    let n = before["count"].as_u64().unwrap_or(0);+    check!(b, "precondition: a full session", n >= 4, s(&before));++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(+        b,+        "the sweep refuses to begin",+        r["success"] == false && r["errorCode"] == "insufficient_headroom",+        s(&r)+    );+    check!(b, "...in one word a caller can branch on", r["stoppedBy"] == "insufficientHeadroom", s(&r));+    check!(b, "...and does not claim to have finished", r["done"] != true, s(&r));++    // THE NUMBERS ARE NAMED — free, needed, and the shortfall between them.+    check!(b, "it says how much room there was", r["headroomBytesBefore"] == 3_000_000, s(&r));+    check!(+        b,+        "...and that it refused BEFORE touching anything, not partway through",+        r["stoppedByHeadroom"]["when"] == "before anything was written",+        s(&r)+    );+    check!(+        b,+        "...the session as a whole is what did not fit",+        r["stoppedByHeadroom"]["neededBytes"].as_u64().unwrap_or(0) > 3_000_000,+        s(&r)+    );+    let est = r["estimatedWriteBytes"].as_u64().unwrap_or(0);+    check!(b, "...and what it was about to write", est > 1024, s(&r));+    check!(+        b,+        "...and the shortfall, without making the caller subtract",+        r["stoppedByHeadroom"]["shortfallBytes"].as_u64().unwrap_or(0) > 0,+        s(&r)+    );++    // NOTHING WAS WRITTEN AND NOTHING WAS CLOSED.+    check!(b, "nothing was preserved", r["recovered"].as_array().map(|a| a.is_empty()).unwrap_or(false), s(&r));+    check!(b, "nothing was closed", r["closed"].as_u64().unwrap_or(99) == 0, s(&r));+    let after = b.call("swx_docs", json!({}));+    check!(b, "the session is untouched", after["count"].as_u64().unwrap_or(0) == n, s(&after));+    // AND NO RECOVERY FILE WAS LEFT BEHIND to be mistaken for a rescue.+    let dir = scratch.join("recovery");+    let files: Vec<_> = std::fs::read_dir(&dir)+        .map(|rd| rd.flatten().map(|e| e.path()).collect())+        .unwrap_or_default();+    check!(b, "no half-written rescue was left on disk", files.is_empty(), format!("{files:?}"));+}++/// AND A DISK THAT FILLS MID-SWEEP STOPS IT. The check before the first save is+/// not enough: this sweep is itself one of the things filling the volume, so the+/// question is asked again before EVERY save — the one that would be truncated,+/// not the first of them.+#[test]+fn a_disk_that_fills_mid_sweep_stops_the_sweep() {+    let scratch = TempDir::new("shutdown-filling");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-filling-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[+            ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+            // Room to start, room for the first save, and then the volume fills.+            ("SWX_TEST_FREE_BYTES", "50000000000,50000000000,2048"),+        ],+    );+    b.call("swx_start", json!({}));+    let before = b.call("swx_docs", json!({}));+    let n = before["count"].as_u64().unwrap_or(0);++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(+        b,+        "the sweep stops when the volume fills under it",+        r["success"] == false && r["errorCode"] == "insufficient_headroom",+        s(&r)+    );+    check!(b, "...and does not claim to have finished", r["done"] != true, s(&r));+    check!(+        b,+        "...saying WHEN it stopped, not just that it did",+        r["stoppedByHeadroom"]["when"].as_str().unwrap_or("").contains("after preserving"),+        s(&r)+    );++    // IT GOT SOMEWHERE FIRST, and what it wrote is real and still there.+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    check!(b, "it preserved what it could before stopping", rec.len() >= 1, s(&r));+    for d in &rec {+        if let Some(rp) = d["recoveryPath"].as_str() {+            check!(+                b,+                "...and that work is really on disk",+                std::path::Path::new(rp).exists() && d["recoverySignatureOk"] == true,+                s(d)+            );+        }+    }+    // BUT NOTHING WAS CLOSED: a sweep that cannot finish preserving must not+    // start closing.+    check!(b, "nothing was closed", r["closed"].as_u64().unwrap_or(99) == 0, s(&r));+    let after = b.call("swx_docs", json!({}));+    check!(b, "every document is still open", after["count"].as_u64().unwrap_or(0) == n, s(&after));+}++/// THE NUMBERS ARE ALWAYS REPORTED, including on the happy path. A sweep that+/// writes every open document to disk and never says how much room it had is+/// asking to be trusted about the one thing it cannot verify afterwards.+#[test]+fn a_successful_sweep_still_reports_the_room_it_had() {+    let scratch = TempDir::new("shutdown-headroom-ok");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-headroom-ok-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep finished", r["success"] == true && r["done"] == true, s(&r));+    check!(+        b,+        "it reports the free space it measured on a REAL volume",+        r["headroomBytesBefore"].as_u64().unwrap_or(0) > 0,+        s(&r)+    );+    check!(+        b,+        "...and what it estimated it would write",+        r["estimatedWriteBytes"].as_u64().unwrap_or(0) > 0,+        s(&r)+    );+    check!(+        b,+        "...and how that estimate was arrived at, since most of it is estimated",+        r["estimatedFrom"]["documents"].as_u64().unwrap_or(0) >= 4+            && r["estimatedFrom"]["estimated"].as_u64().unwrap_or(0) >= 1,+        s(&r)+    );+}++/// THE WIRE: the document's real TYPE decides the recovery file's name AND the+/// floor it is verified against.+///+/// This is the chain pass 26 broke open, asserted end to end rather than in+/// pieces. A dirty assembly must be written as `.SLDASM` and held to the+/// assembly floor. Break the type-to-extension map (`Assembly => "SLDPRT"`) and+/// this fails — which is what makes it a test of the WIRE and not of either end.+#[test]+fn an_assemblys_type_decides_both_its_recovery_name_and_its_floor() {+    let scratch = TempDir::new("shutdown-kindwire");+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 1, "active": "untitled:a",+            "docs": [+                // A DIRTY, PATHLESS ASSEMBLY: nothing but its type says what it+                // is — no path, and a title SOLIDWORKS made up.+                {"docType": "assembly", "path": "untitled:a", "title": "Assem1",+                 "saved": false, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-kindwire-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep finished", r["success"] == true && r["done"] == true, s(&r));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let d = rec.first().cloned().unwrap_or(json!({}));+    check!(b, "the document was preserved", d["preserved"] == true, s(&r));+    // THE TYPE REACHED THE NAME.+    let rp = d["recoveryPath"].as_str().unwrap_or("").to_string();+    check!(b, "an assembly is written as an assembly", rp.ends_with(".SLDASM"), s(&d));+    // THE TYPE REACHED THE REPORT.+    check!(b, "and the kind it was judged as is published", d["docKind"] == "assembly", s(&d));+    // AND THE ASSEMBLY FLOOR WAS THE ONE APPLIED: the file clears 80 KB, which a+    // part-sized rescue of the same document would not have.+    check!(+        b,+        "an assembly's rescue is at least an assembly's worth of bytes",+        d["recoveryBytes"].as_u64().unwrap_or(0) >= 80 * 1024,+        s(&d)+    );+}++/// A DOCUMENT WHOSE TYPE COULD NOT BE READ IS CLASSIFIED FROM ITS OWN PATH.+///+/// `GetType` is a COM call on a document being closed under memory pressure —+/// the condition this sweep exists for. When it fails, the document's own file+/// path is the strongest evidence in the room: the user's file, named by+/// SOLIDWORKS, on disk. It is NOT the circularity 0.7.5 removed, and the+/// difference is provenance — the path describes the document; the recovery+/// file's name is chosen by this sweep FROM the kind, so reading a kind back out+/// of that can only repeat what was already assumed.+#[test]+fn an_unreadable_type_falls_back_to_the_documents_own_path() {+    let scratch = TempDir::new("shutdown-pathkind");+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 0, "active": "C:\\Users\\drew\\work\\rig.SLDASM",+            "docs": [+                // A SAVED, DIRTY ASSEMBLY whose TITLE has no extension —+                // Explorer hides known types by default, so this is the common+                // case and not a contrived one. Its path says what it is.+                {"docType": "assembly", "path": "C:\\Users\\drew\\work\\rig.SLDASM",+                 "title": "rig", "saved": true, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-pathkind-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-doctype-unreadable"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let d = rec.first().cloned().unwrap_or(json!({}));+    check!(b, "the document was preserved", d["preserved"] == true, s(&r));+    check!(+        b,+        "an unreadable type falls back to the document's own path, not to a guess",+        d["docKind"] == "assembly",+        s(&d)+    );+    check!(+        b,+        "...so it is written as an assembly",+        d["recoveryPath"].as_str().unwrap_or("").ends_with(".SLDASM"),+        s(&d)+    );+    check!(+        b,+        "...and held to the assembly floor",+        d["recoveryBytes"].as_u64().unwrap_or(0) >= 80 * 1024,+        s(&d)+    );+}++/// AND WHEN NOTHING ANYWHERE SAYS WHAT IT IS, nothing is INVENTED — even though+/// something has to be written on the file.+///+/// Measured live 2026-09-17: `SaveAs3` to a neutral `.sldrecover` name answers+/// "format not supported" and writes nothing, so the honest name is not a name+/// this install will accept. The file is therefore named as a PART — and the+/// record says `namingFallback: "unknownKindNamedAsPart"`, the kind stays null,+/// and the verification still applies the strictest floor. The name is a label+/// SOLIDWORKS forced; the floor is the check.+#[test]+fn a_document_nothing_can_classify_is_named_as_a_part_but_never_called_one() {+    let scratch = TempDir::new("shutdown-unknownkind");+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 1, "active": "untitled:a",+            "docs": [+                // No path, and a title with no extension: SOLIDWORKS' own+                // default name for an unsaved assembly.+                {"docType": "assembly", "path": "untitled:a", "title": "Assem1",+                 "saved": false, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-unknownkind-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-doctype-unreadable"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let d = rec.first().cloned().unwrap_or(json!({}));+    check!(b, "the kind is reported as unknown, not invented", d["docKind"].is_null(), s(&r));+    check!(+        b,+        "...the name is a part only because SOLIDWORKS accepts no other",+        d["recoveryPath"].as_str().unwrap_or("").ends_with(".SLDPRT"),+        s(&d)+    );+    check!(+        b,+        "...and the record says that name was a guess",+        d["namingFallback"] == "unknownKindNamedAsPart",+        s(&d)+    );+    check!(+        b,+        "...and it is held to the strictest floor, not the part floor its name implies",+        d["preserved"] != true || d["recoveryBytes"].as_u64().unwrap_or(0) >= 256 * 1024,+        s(&d)+    );+}++/// AND IF A FUTURE VERSION ACCEPTS THE NEUTRAL NAME, the honest name is used and+/// nothing is recorded as a guess. The path is kept alive and tested rather than+/// deleted, because the measurement that closed it is about THIS install and this+/// version.+#[test]+fn a_neutral_name_is_used_where_solidworks_accepts_one() {+    let scratch = TempDir::new("shutdown-neutralok");+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 1, "active": "untitled:a",+            "docs": [+                {"docType": "assembly", "path": "untitled:a", "title": "Assem1",+                 "saved": false, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-neutralok-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-doctype-unreadable"), "").unwrap();+    std::fs::write(b.scratch.join("mock-neutral-extension-accepted"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let d = r["recovered"].as_array().and_then(|a| a.first()).cloned().unwrap_or(json!({}));+    check!(+        b,+        "the honest neutral name is used when it can be",+        d["recoveryPath"].as_str().unwrap_or("").ends_with(".sldrecover"),+        s(&d)+    );+    check!(b, "...and nothing is recorded as a guess", d["namingFallback"].is_null(), s(&d));+    check!(b, "...and the kind is still unknown", d["docKind"].is_null(), s(&d));+}++/// IF SOLIDWORKS WILL NOT WRITE THE NEUTRAL NAME, the document is re-saved as a+/// part and TOLD SO — and the verification does not soften.+///+/// `SaveAs3` dispatches its writer on the extension, and `.sldrecover` matches+/// none. Whether it fails or silently falls back is not something this bridge+/// knows, so it is not assumed: the save is attempted and the failure handled. A+/// guessed NAME is tolerable now in a way it was not before 0.7.5, because+/// nothing downstream reads the kind back out of it — the unknown-kind floor+/// still applies, and the record says `unknownKindNamedAsPart` so nobody reads+/// the `.SLDPRT` as a statement of fact.+#[test]+fn a_refused_neutral_name_falls_back_to_a_part_and_says_so() {+    let scratch = TempDir::new("shutdown-namefallback");+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 1, "active": "untitled:a",+            "docs": [+                {"docType": "assembly", "path": "untitled:a", "title": "Assem1",+                 "saved": false, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-namefallback-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-doctype-unreadable"), "").unwrap();+    std::fs::write(b.scratch.join("mock-saveas-rejects-unknown-extension"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let d = rec.first().cloned().unwrap_or(json!({}));+    check!(b, "the document was still preserved", d["preserved"] == true, s(&r));+    check!(+        b,+        "the name fell back to a part",+        d["recoveryPath"].as_str().unwrap_or("").ends_with(".SLDPRT"),+        s(&d)+    );+    check!(+        b,+        "...and says the name is a fallback, not a finding",+        d["namingFallback"] == "unknownKindNamedAsPart",+        s(&d)+    );+    // THE VERIFICATION DID NOT SOFTEN. The kind is still unknown, so the file had+    // to clear the STRICTEST floor — not the 24 KB part floor its name implies.+    check!(b, "the kind is still reported as unknown", d["docKind"].is_null(), s(&d));+    check!(+        b,+        "...and it was held to the strictest floor despite the part name",+        d["recoveryBytes"].as_u64().unwrap_or(0) >= 256 * 1024,+        s(&d)+    );+}++/// EVERY RESCUE CARRIES ITS OWN INSTRUCTION, and the one for a file SOLIDWORKS+/// cannot open is not the one for a file it can.+///+/// A `.sldrecover` file is the rescue of a document that was never saved — the+/// user's only copy — and SOLIDWORKS dispatches on extension, so `swx_open` is+/// the single instruction that cannot work for it. The general hint saying so is+/// not enough: a caller reads the entry, not the preamble, and a list where every+/// file says the same thing tells nobody which one is the problem.+#[test]+fn recover_list_tells_each_file_how_to_be_reopened() {+    let scratch = TempDir::new("shutdown-howtoopen");+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 1, "active": "untitled:a",+            "docs": [+                // One the bridge can classify, and one nothing can.+                {"docType": "part", "path": "C:\\Users\\drew\\work\\bracket.SLDPRT",+                 "title": "bracket.SLDPRT", "saved": true, "dirty": true},+                {"docType": "assembly", "path": "untitled:a", "title": "Assem1",+                 "saved": false, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-howtoopen-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    std::fs::write(b.scratch.join("mock-doctype-unreadable"), "").unwrap();+    let swept = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "precondition: the sweep preserved both", swept["recovered"].as_array().map(|a| a.len()) == Some(2), s(&swept));++    let r = b.call("swx_recover_list", json!({}));+    let files: Vec<Value> = r["sweeps"]+        .as_array()+        .and_then(|a| a.first())+        .and_then(|s| s["files"].as_array())+        .cloned()+        .unwrap_or_default();+    check!(b, "both rescues are listed", files.len() == 2, s(&r));++    // SELECTED BY WHAT IS KNOWN ABOUT THE DOCUMENT, not by how the file is+    // spelled. Since SOLIDWORKS refuses a neutral extension, an unclassifiable+    // document is WRITTEN as `.SLDPRT` — so a test that picked by extension would+    // have quietly stopped testing anything.+    let unknown = files+        .iter()+        .find(|f| f["kindKnown"] == false)+        .cloned()+        .unwrap_or(json!({}));+    let known = files+        .iter()+        .find(|f| f["kindKnown"] == true)+        .cloned()+        .unwrap_or(json!({}));++    check!(b, "the unclassifiable rescue is marked as such", unknown["kindKnown"] == false, s(&unknown));+    check!(+        b,+        "...and its entry discloses that its NAME was a guess, which no file name can say",+        unknown["namingFallback"] == "unknownKindNamedAsPart",+        s(&unknown)+    );+    let how = unknown["howToOpen"].as_str().unwrap_or("").to_ascii_lowercase();+    check!(b, "...and its OWN entry says to rename it", how.contains("rename"), s(&unknown));+    check!(b, "...saying the extension was a guess, not an observation", how.contains("guess"), s(&unknown));+    check!(+        b,+        "...naming the extensions to try, in order",+        how.contains(".sldprt") && how.contains(".sldasm") && how.contains(".slddrw"),+        s(&unknown)+    );++    // AND THE ORDINARY ONE IS NOT DRESSED UP AS A PROBLEM: a list where every+    // entry warns is a list nobody reads.+    check!(b, "the ordinary rescue is marked openable", known["kindKnown"] == true, s(&known));+    let how_known = known["howToOpen"].as_str().unwrap_or("").to_ascii_lowercase();+    check!(b, "...and simply says to open it", how_known.contains("swx_open") && !how_known.contains("rename"), s(&known));+}++/// A LISTING MUST TELL A RESCUE FROM A HUSK.+///+/// Files land in a sweep directory whether or not the sweep vouched for them: a+/// refused save leaves a husk, a save that returns success can write something+/// that is not a document, and a full disk leaves a truncation. In every one of+/// those the DOCUMENT WAS LEFT OPEN — the work is still in SOLIDWORKS, not in the+/// folder. `swx_recover_list` is the verb the user reads on the worst day, and+/// presenting those leftovers as rescued work is the one lie that matters here.+///+/// The verdict is a pure function of the file, so it is simply re-run per file.+#[test]+fn recover_list_never_calls_an_unverified_file_a_rescue() {+    let scratch = TempDir::new("shutdown-husk");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    let root = scratch.join("recovery").join("2026-09-12T00-00-00");+    std::fs::create_dir_all(&root).unwrap();++    // A GENUINE RESCUE: the marker, and a part's worth of bytes.+    let mut good = vec![0x9fu8, 0xe4, 0x18, 0x9f, 0x00, 0x00, 0x00, 0x04];+    good.resize(64 * 1024, b'x');+    std::fs::write(root.join("bracket.SLDPRT"), &good).unwrap();+    // A HUSK: plenty of bytes, no marker. This is why a document is still open.+    std::fs::write(root.join("Assem1.sldrecover"), vec![0x41u8; 900_000]).unwrap();+    // A TRUNCATION: the right marker, a third of a part.+    let mut short = good.clone();+    short.truncate(8 * 1024);+    std::fs::write(root.join("shaft.SLDPRT"), &short).unwrap();++    let r = b.call("swx_recover_list", json!({}));+    let files = r["sweeps"][0]["files"].as_array().cloned().unwrap_or_default();+    check!(b, "everything on disk is still listed", files.len() == 3, s(&r));++    let by = |n: &str| files.iter().find(|f| f["name"] == n).cloned().unwrap_or(json!({}));++    // A FOLDER WITH NO MANIFEST IS AN OLD ONE (or one whose record was lost), and+    // the honest answer about a file that merely LOOKS right is UNKNOWN: this verb+    // cannot see what the original document weighed, nor whether the extension was+    // a guess, and both are what the sweep's own verdict turned on. A weaker check+    // does not produce false negatives — it produces false POSITIVES, and+    // `verified:true` for a file the sweep refused is the error that costs the user.+    let good_f = by("bracket.SLDPRT");+    check!(b, "a re-checked file is not CLAIMED as verified", good_f["verified"].is_null(), s(&good_f));+    check!(b, "...and says where the answer came from", good_f["source"] == "reverified", s(&good_f));+    check!(+        b,+        "...and is offered as unconfirmed rather than as a rescue",+        good_f["howToOpen"].as_str().unwrap_or("").contains("UNCONFIRMED"),+        s(&good_f)+    );++    for name in ["Assem1.sldrecover", "shaft.SLDPRT"] {+        let f = by(name);+        check!(b, "a file the sweep refused is NOT verified", f["verified"] == false, s(&f));+        let how = f["howToOpen"].as_str().unwrap_or("");+        check!(b, "...and is not called a rescue", how.contains("NOT A RESCUE"), s(&f));+        // THE SWEEP'S OWN REASON, not a generic apology.+        check!(+            b,+            "...and carries the reason the sweep gave",+            f["verifiedReason"].as_str().unwrap_or("").len() > 20,+            s(&f)+        );+        // AND IT IS NEVER PRESENTED AS OPENABLE — not by swx_open, and not by+        // renaming, which for a husk would just produce an unopenable .SLDPRT.+        check!(+            b,+            "...and is never offered as something to open",+            !how.contains("swx_open") && !how.to_lowercase().contains("rename"),+            s(&f)+        );+        // WHERE THE WORK IS. Without a manifest this verb cannot state that the+        // sweep left the document open — only that if it is open, the work is+        // there — and it is worded accordingly. The manifest path says it flatly.+        check!(+            b,+            "...and points at SOLIDWORKS as the place the work still is",+            how.contains("still open in SOLIDWORKS"),+            s(&f)+        );+    }++    // THE HEADLINE MUST NOT SAY EVERYTHING HERE IS RESCUED WORK.+    let hint = r["_hint"].as_str().unwrap_or("");+    check!(+        b,+        "the hint warns about unverified files instead of claiming they are documents",+        hint.contains("verified:false") && hint.contains("NOT complete documents"),+        hint.to_string()+    );+}++/// THE SWEEP'S OWN RECORD IS WHAT THE LISTING REPORTS.+///+/// Everything the listing used to re-derive — what the original document weighed,+/// what kind `GetType` reported, whether the extension was a guess — was in hand+/// at save time and thrown away when the process ended. Re-deriving without them+/// is strictly weaker than the verdict it is reproducing, and a weaker check does+/// not produce false negatives; it produces FALSE POSITIVES. `verified:true` for a+/// file the sweep refused, read by the person whose sweep just failed, is the one+/// error here that costs something.+#[test]+fn the_listing_reports_the_sweeps_own_verdicts_from_its_manifest() {+    let scratch = TempDir::new("shutdown-manifest");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-manifest-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let swept = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close"}));+    check!(b, "precondition: the sweep ran", swept["done"] == true, s(&swept));++    let r = b.call("swx_recover_list", json!({}));+    let sweep = r["sweeps"].as_array().and_then(|a| a.first()).cloned().unwrap_or(json!({}));+    let dir = sweep["directory"].as_str().unwrap_or("").to_string();+    let files = sweep["files"].as_array().cloned().unwrap_or_default();+    check!(b, "the rescues are listed", !files.is_empty(), s(&r));++    // THE RECORD IS ON DISK, and it is not itself listed as a rescue.+    let manifest_path = std::path::Path::new(&dir).join("manifest.json");+    check!(b, "the sweep wrote its record beside the files", manifest_path.exists(), dir.clone());+    check!(+        b,+        "...and the record is not offered to the user as recovered work",+        !files.iter().any(|f| f["name"] == "manifest.json"),+        s(&sweep)+    );+    let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap_or_default())+        .unwrap_or(json!({}));+    check!(+        b,+        "the record carries the sweep's reason and every file it decided about",+        m["reason"].as_str().unwrap_or("").contains("asked me to close")+            && m["files"].as_array().map(|a| !a.is_empty()).unwrap_or(false),+        s(&m)+    );+    // THE FACTS A FILE NAME CANNOT CARRY.+    let entry = m["files"].as_array().and_then(|a| a.first()).cloned().unwrap_or(json!({}));+    for field in ["originalBytes", "kind", "namingFallback", "signatureOk", "closed", "verdictReason"] {+        check!(b, "...including what no listing could re-derive", entry.get(field).is_some(), format!("{field}: {}", s(&entry)));+    }++    // AND THE LISTING ANSWERS FROM IT, saying so.+    for f in &files {+        check!(b, "every entry says where its verdict came from", f["source"] == "manifest", s(f));+        check!(b, "...and carries a real verdict, not an unknown", f["verified"] == true, s(f));+        check!(+            b,+            "...and says whether the document was actually closed",+            f["closed"] == true,+            s(f)+        );+    }+}++/// A SWEEP THAT IS STILL RUNNING HAS ALREADY WRITTEN DOWN WHAT IT DECIDED.+///+/// The record is rewritten after every save and every close, atomically, so a+/// bridge that dies mid-sweep leaves a manifest describing everything decided up+/// to that moment. That is the case that matters most: a sweep interrupted by a+/// crash is exactly the one whose files somebody reads later from a fresh bridge.+#[test]+fn an_interrupted_sweep_still_leaves_a_record_of_what_it_decided() {+    let scratch = TempDir::new("shutdown-partialmanifest");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-partialmanifest-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    // The volume fills after the first save: the sweep stops part-way, exactly as+    // an interruption would leave it.+    std::fs::write(b.scratch.join("mock-dialog-after-save"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "precondition: the sweep stopped part-way", r["done"] != true, s(&r));++    let listed = b.call("swx_recover_list", json!({}));+    let sweep = listed["sweeps"].as_array().and_then(|a| a.first()).cloned().unwrap_or(json!({}));+    let dir = sweep["directory"].as_str().unwrap_or("").to_string();+    check!(+        b,+        "a record exists for the part that ran",+        std::path::Path::new(&dir).join("manifest.json").exists(),+        s(&listed)+    );+    let files = sweep["files"].as_array().cloned().unwrap_or_default();+    check!(b, "and what was written is reported from it", files.iter().all(|f| f["source"] == "manifest"), s(&sweep));+    // NOTHING WAS CLOSED — the sweep stopped — and the record says so, which is+    // the difference between "your work is in this file" and "your work is still+    // in SOLIDWORKS".+    check!(+        b,+        "the record does not claim documents were closed when none were",+        files.iter().all(|f| f["closed"] == false),+        s(&sweep)+    );+    // No temporary file is left lying around pretending to be a rescue.+    check!(+        b,+        "the atomic write leaves no half-written record behind",+        !std::path::Path::new(&dir).join("manifest.json.tmp").exists(),+        dir+    );+}++/// TWO SWEEPS IN THE SAME SECOND DO NOT SHARE A FOLDER — OR A RECORD.+///+/// The sweep folder is named to the second. Two sweeps a fraction apart used to+/// land in the same one, and since the manifest arrived that means sharing the+/// record: the second sweep's `manifest.json` overwrites the first's, so the+/// first sweep's files sit there with nothing describing them — and if the second+/// preserved nothing, its cleanup deleted the first's manifest outright.+///+/// That is a reporting defect with a real victim (the record of why a document+/// was not preserved) and it was also the mechanism behind an intermittent+/// failure in the manifest test, about one run in twelve. It is fixed by giving+/// each sweep its own folder, not by retrying the test.+#[test]+fn two_sweeps_in_the_same_second_keep_separate_records() {+    let scratch = TempDir::new("shutdown-samesecond");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-samesecond-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    // FIRST SWEEP: preserves and closes everything.+    let first = b.call("swx_close_all_safely", json!({"reason": "first sweep"}));+    check!(b, "the first sweep ran", first["done"] == true, s(&first));+    let first_dir = first["recoveryDir"].as_str().unwrap_or("").to_string();+    let first_manifest = std::path::Path::new(&first_dir).join("manifest.json");+    check!(b, "it left a record", first_manifest.exists(), first_dir.clone());+    let first_text = std::fs::read_to_string(&first_manifest).unwrap_or_default();++    // SECOND SWEEP, IMMEDIATELY: the session is empty, so it preserves nothing —+    // the case whose cleanup used to delete somebody else's record.+    let second = b.call("swx_close_all_safely", json!({"reason": "second sweep"}));+    let second_dir = second["recoveryDir"].as_str().unwrap_or("").to_string();+    check!(+        b,+        "the second sweep got a folder of its own",+        second_dir != first_dir,+        format!("both sweeps used {first_dir}")+    );++    // THE FIRST SWEEP'S RECORD IS UNTOUCHED.+    check!(b, "the first record still exists", first_manifest.exists(), first_dir.clone());+    check!(+        b,+        "...and still says what the first sweep decided",+        std::fs::read_to_string(&first_manifest).unwrap_or_default() == first_text,+        first_dir.clone()+    );+    let m: Value = serde_json::from_str(&first_text).unwrap_or(json!({}));+    check!(b, "...naming its own reason, not the later sweep's", m["reason"] == "first sweep", s(&m));++    // AND THE LISTING STILL DESCRIBES EVERY FILE IT HOLDS.+    let listed = b.call("swx_recover_list", json!({}));+    let sweep = listed["sweeps"]+        .as_array()+        .and_then(|a| a.iter().find(|s| s["directory"] == json!(first_dir.clone())))+        .cloned()+        .unwrap_or(json!({}));+    let files = sweep["files"].as_array().cloned().unwrap_or_default();+    check!(b, "the first sweep's files are listed", !files.is_empty(), s(&listed));+    check!(+        b,+        "...every one of them from its own sweep's record",+        files.iter().all(|f| f["source"] == "manifest"),+        s(&sweep)+    );+}++/// THE RECORD IS WRITTEN ATOMICALLY, and nothing half-written is left behind.+///+/// A manifest is believed by the listing, so a truncated one is worse than none:+/// it would describe some files and silently omit others that are really there.+/// It is written to a temporary path and renamed.+#[test]+fn the_record_is_written_atomically() {+    // The write goes via a temp path — asserted against the source, because the+    // failure it prevents cannot be produced on demand from outside.+    let src = std::fs::read_to_string(+        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/verbs3.rs"),+    )+    .expect("verbs3.rs");+    let body = src+        .split("fn write_manifest(")+        .nth(1)+        .and_then(|r| r.split("\nfn ").next())+        .unwrap_or("");+    assert!(+        body.contains("manifest.json.tmp") && body.contains("rename"),+        "write_manifest must write a temporary file and RENAME it. A manifest is believed by \+         swx_recover_list, so a half-written one describes some rescues and silently omits \+         others. Body:\n{body}"+    );++    // And no temporary file survives a completed sweep.+    let scratch = TempDir::new("shutdown-atomic");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-atomic-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let dir = r["recoveryDir"].as_str().unwrap_or("").to_string();+    check!(+        b,+        "no half-written record is left behind",+        !std::path::Path::new(&dir).join("manifest.json.tmp").exists(),+        dir.clone()+    );+    check!(b, "and the real one is there", std::path::Path::new(&dir).join("manifest.json").exists(), dir);+}++/// A DIALOG IS REPORTED BY WHAT IT SAYS, NOT JUST BY ITS TITLE.+///+/// Live, 2026-09-17: the sweep stopped correctly on+/// `{"kind":"license","title":"License Manager"}` — and that was everything the+/// caller got. Read by hand afterwards, the window said:+///+/// > Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional: Unable+/// > to connect to server. The session will end after the next 5 unsuccessful+/// > attempts.+///+/// with a single button: OK. A title cannot distinguish "your licence expired"+/// from "the licence server is unreachable and this session dies in five more+/// tries" — and the second is a countdown somebody needs to see. It appeared when+/// the 3DEXPERIENCE licence heartbeat failed, coinciding with the desktop+/// dropping off the relay, and both the sweep and the quit are blocked while it+/// is up.+///+/// READING IS NOT ANSWERING. Nothing here presses anything, and `neverAnswer`+/// stays true even for a dialog whose only button is OK: whether this bridge may+/// ever press one is a policy question for the user.+#[test]+fn a_blocking_dialog_reports_its_text_and_its_buttons() {+    let machine = TempDir::new("shutdown-licence-machine");+    std::fs::write(+        machine.join("dialog-open.json"),+        json!({+            "hwnd": 9400, "className": "#32770", "title": "License Manager", "visible": true,+            "text": ["Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional: \+                      Unable to connect to server. The session will end after the next 5 \+                      unsuccessful attempts."],+            "buttons": ["OK"]+        })+        .to_string(),+    )+    .unwrap();+    let scratch = TempDir::new("shutdown-licence");+    a_difficult_session(&scratch);+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep stops", r["errorCode"] == "stopped_by_dialog", s(&r));+    let d = r["stoppedByDialog"].clone();+    check!(b, "the dialog is classified", d["kind"] == "license", s(&d));+    check!(b, "...its contents were read", d["contentsReadable"] == true, s(&d));+    check!(+        b,+        "...and the caller is told WHAT it says, not just its title",+        d["text"][0].as_str().unwrap_or("").contains("Unable to connect to server")+            && d["text"][0].as_str().unwrap_or("").contains("next 5"),+        s(&d)+    );+    check!(b, "...and WHICH buttons exist", d["buttons"][0] == "OK", s(&d));+    check!(b, "...that the only choice is to acknowledge it", d["acknowledgeOnly"] == true, s(&d));+    // AND IT IS STILL NEVER ANSWERED.+    check!(b, "the bridge will not press it", d["neverAnswer"] == true, s(&d));+    let after = b.call("swx_docs", json!({}));+    check!(b, "nothing was closed behind it", after["count"].as_u64().unwrap_or(0) >= 4, s(&after));++    // A DIALOG THAT ASKS SOMETHING IS NOT ACKNOWLEDGE-ONLY. The distinction has+    // to discriminate, or it is just a field that always says yes — and this one+    // exists to feed a policy decision about what may ever be pressed.+    let machine2 = TempDir::new("shutdown-choice-machine");+    std::fs::write(+        machine2.join("dialog-open.json"),+        json!({+            "hwnd": 9402, "className": "#32770", "title": "Save Modified Documents",+            "visible": true,+            "text": ["Save changes to Part1?"],+            "buttons": ["Save all to This PC", "Don't Save", "Cancel"]+        })+        .to_string(),+    )+    .unwrap();+    let scratch2 = TempDir::new("shutdown-choice");+    a_difficult_session(&scratch2);+    let b2 = Bridge::start_in(+        TempDir::borrowed(&scratch2.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine2.path())],+    );+    b2.call("swx_start", json!({}));+    let r2 = b2.call("swx_close_all_safely", json!({"reason": "closing"}));+    let d2 = r2["stoppedByDialog"].clone();+    check!(+        b2,+        "a dialog offering real choices is NOT acknowledge-only",+        d2["acknowledgeOnly"] == false,+        s(&d2)+    );+    check!(+        b2,+        "...and every choice it offers is named, since each does something different to the work",+        d2["buttons"].as_array().map(|a| a.len()) == Some(3),+        s(&d2)+    );+    check!(b2, "...and it is never answered either", d2["neverAnswer"] == true, s(&d2));+}++/// AN UNREADABLE DIALOG IS UNKNOWN, NOT SILENT. The stop still happens; the+/// contents are null and say so.+#[test]+fn a_dialog_whose_controls_cannot_be_read_says_so() {+    let machine = TempDir::new("shutdown-opaque-machine");+    std::fs::write(+        machine.join("dialog-open.json"),+        json!({"hwnd": 9401, "className": "#32770", "title": "License Manager", "visible": true})+            .to_string(),+    )+    .unwrap();+    std::fs::write(machine.join("dialog-contents-unreadable"), "").unwrap();+    let scratch = TempDir::new("shutdown-opaque");+    a_difficult_session(&scratch);+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let d = r["stoppedByDialog"].clone();+    check!(b, "the sweep still stops", r["errorCode"] == "stopped_by_dialog", s(&r));+    check!(b, "the contents are UNKNOWN, not empty", d["contentsReadable"] == false, s(&d));+    check!(b, "...reported as null rather than as no text", d["text"].is_null(), s(&d));+    check!(b, "...and acknowledgeOnly is not guessed", d["acknowledgeOnly"].is_null(), s(&d));+    check!(b, "...and it is still never answered", d["neverAnswer"] == true, s(&d));+}++/// A DOCUMENT THE SWEEP REFUSED IS RECORDED AS REFUSED — in the manifest, which+/// is the only thing `swx_recover_list` trusts afterwards.+///+/// Every other sweep test preserves everything, so `preserved` is true for every+/// record and `write_manifest`'s `verified` field is true either way. Two+/// mutations survived on that: `verified` hard-coded to true, and+/// `host_failure_entry` dropping `hostError`. Both are the fixes from passes 29+/// and 30 — the manifest being the permanent record of WHY a document was not+/// preserved — and neither had a test that could tell.+///+/// So this sweep refuses one document (the write is truncated, so the bytes are+/// not a document) and asserts the refusal survives into the record and out+/// through the listing.+#[test]+fn a_refused_document_is_recorded_as_refused_in_the_manifest() {+    let scratch = TempDir::new("shutdown-refused-record");+    a_difficult_session(&scratch);+    let machine = a_visible_machine("shutdown-refused-record-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    // A TRUNCATED WRITE: a real header, then the disk stops. The sweep refuses+    // these and leaves the documents open.+    std::fs::write(b.scratch.join("mock-recovery-truncated"), "").unwrap();++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    check!(b, "the sweep did not finish", r["done"] != true, s(&r));+    let rec = r["recovered"].as_array().cloned().unwrap_or_default();+    let refused: Vec<&Value> = rec.iter().filter(|d| d["preserved"] == false).collect();+    check!(b, "at least one document was refused", !refused.is_empty(), s(&r));++    // ── THE MANIFEST CARRIES THE REFUSAL, not a blanket yes.+    let dir = r["recoveryDir"].as_str().unwrap_or("").to_string();+    let m: Value = serde_json::from_str(+        &std::fs::read_to_string(std::path::Path::new(&dir).join("manifest.json"))+            .unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    let entries = m["files"].as_array().cloned().unwrap_or_default();+    check!(b, "the record exists and lists files", !entries.is_empty(), s(&m));+    let any_false = entries.iter().any(|e| e["verified"] == false);+    check!(+        b,+        "a refused document is recorded verified:false — the manifest is what swx_recover_list \+         trusts, so a blanket true would tell the user their only copy is intact",+        any_false,+        s(&m)+    );+    for e in entries.iter().filter(|e| e["verified"] == false) {+        check!(+            b,+            "...with the sweep's own reason for refusing it",+            e["verdictReason"].as_str().map(|r| r.len() > 20).unwrap_or(false),+            s(e)+        );+        check!(b, "...and it was not closed", e["closed"] == false, s(e));+    }++    // ── AND THE LISTING REPORTS IT AS NOT A RESCUE.+    let listed = b.call("swx_recover_list", json!({}));+    let files = listed["sweeps"]+        .as_array()+        .and_then(|a| a.iter().find(|s| s["directory"] == json!(dir.clone())))+        .and_then(|s| s["files"].as_array())+        .cloned()+        .unwrap_or_default();+    let unverified: Vec<&Value> = files.iter().filter(|f| f["verified"] == false).collect();+    check!(b, "the listing carries the refusal through", !unverified.is_empty(), s(&listed));+    // FROM THE MANIFEST, not from a re-check. The reverified path also produces+    // `verified:false` for bytes that are not a document — so without this, a+    // manifest that recorded nothing at all would still satisfy the assertion+    // above, and the guard being pinned here is `write_manifest`'s verdict.+    check!(+        b,+        "...and it came from the sweep's own record, not from re-reading the file",+        unverified.iter().all(|f| f["source"] == "manifest"),+        s(&listed)+    );+    check!(+        b,+        "...and never calls it a rescue",+        unverified.iter().all(|f| f["howToOpen"].as_str().unwrap_or("").contains("NOT A RESCUE")),+        s(&listed)+    );+}++/// A HOST FAILURE INSIDE THE SWEEP KEEPS ITS STRUCTURE — the other mutation that+/// survived. `host_failure_entry` exists because the sweep's records are written+/// into `manifest.json` and KEPT, so a prose-only verdict is the permanent record+/// of why a document was not preserved.+#[test]+fn a_host_failure_during_a_sweep_carries_its_structured_reason() {+    let scratch = TempDir::new("shutdown-hostfail-record");+    a_difficult_session(&scratch);+    // A FAILURE THAT CARRIES REAL COM DETAIL. `mock-com-timeout` produces an+    // error with NO data, and `Value::Null` satisfies `.is_some()` — so a test+    // built on it cannot tell "the host sent nothing" from "nothing constructs+    // the field", which is the mutation that survived.+    std::fs::write(scratch.join("mock-preserve-host-error-with-data"), "").unwrap();+    let machine = a_visible_machine("shutdown-hostfail-record-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );++    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let failed = r["closeFailed"].as_array().cloned().unwrap_or_default();+    check!(b, "the host failed the sweep", !failed.is_empty(), s(&r));+    let e = failed.first().cloned().unwrap_or(json!({}));+    check!(+        b,+        "the entry says the host errored",+        e["why"].as_str().unwrap_or("").contains("com host errored"),+        s(&e)+    );+    // THE DATA ITSELF, not merely a field that exists. `Value::Null.is_some()` is+    // true, so asserting presence proved nothing.+    check!(+        b,+        "...carrying the COM failure the host actually reported",+        e["hostError"]["hresult"] == "0x80020005" && e["hostError"]["call"] == "SaveAs3",+        s(&e)+    );+    check!(+        b,+        "...and the host's own detail",+        e["detail"].as_str().unwrap_or("").contains("0x80020005"),+        s(&e)+    );++    // AND IT REACHES THE MANIFEST, which is the permanent record of why a+    // document was not preserved — a sweep that dies on the host leaves FEWER+    // files than documents, and this is the only thing that can explain that.+    let dir = r["recoveryDir"].as_str().unwrap_or("").to_string();+    let m: Value = serde_json::from_str(+        &std::fs::read_to_string(std::path::Path::new(&dir).join("manifest.json"))+            .unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    let failures = m["failures"].as_array().cloned().unwrap_or_default();+    check!(b, "the record names the host failure", !failures.is_empty(), s(&m));+    check!(+        b,+        "...with its structured COM detail intact",+        failures.iter().any(|f| f["hostError"]["hresult"] == "0x80020005"),+        s(&m)+    );+}++/// A CLEAN, SAVED DOCUMENT IS CLOSED IN PLACE — AND THE RECORD SAYS SO.+///+/// Measured live 2026-09-30: Drew's saved `R_0603_1608Metric_1.step-1.sldprt`+/// was closed by the sweep with its original untouched (32,311 bytes, same+/// mtime). That is correct — its own file IS its preservation, so the sweep+/// writes no copy — but the record had no entry for it, so `swx_recover_list`+/// could not tell a user "your file was closed at its own path, untouched",+/// which is the most reassuring thing it has to say. "Untouched" is measured,+/// not assumed: the listing compares the file now with what the sweep recorded,+/// and says when something has written to it since, or when it is gone.+#[test]+fn a_clean_document_is_recorded_as_closed_in_place_and_listed_untouched() {+    let scratch = TempDir::new("shutdown-inplace");+    let work = TempDir::new("shutdown-inplace-work");+    let theirs = work.join("R_0603_1608Metric_1.step-1.SLDPRT");+    let mut body = vec![0x9f, 0xe4, 0x18, 0x9f, 0x00, 0x00, 0x00, 0x04];+    body.resize(32_311, b'x');+    std::fs::write(&theirs, &body).unwrap();+    let mtime_before = std::fs::metadata(&theirs).unwrap().modified().unwrap();+    let theirs_s = theirs.to_string_lossy().to_string();+    std::fs::write(+        scratch.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 1, "active": "untitled:a",+            "docs": [+                {"docType": "part", "path": "untitled:a", "title": "Part2",+                 "saved": false, "dirty": true},+                {"docType": "part", "path": theirs_s,+                 "title": "R_0603_1608Metric_1.step-1.SLDPRT", "saved": true, "dirty": false}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+    let machine = a_visible_machine("shutdown-inplace-machine");+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let r = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close SOLIDWORKS"}));+    check!(b, "precondition: the sweep ran and emptied the session", r["done"] == true, s(&r));+    check!(+        b,+        "swx_close_all_safely: the user's own file is byte-identical, mtime unchanged",+        std::fs::read(&theirs).ok().as_deref() == Some(&body[..])+            && std::fs::metadata(&theirs).and_then(|m| m.modified()).ok() == Some(mtime_before),+        theirs_s.clone()+    );++    // THE RECORD.+    let dir = std::path::PathBuf::from(r["recoveryDir"].as_str().unwrap_or(""));+    let m: Value = serde_json::from_str(+        &std::fs::read_to_string(dir.join("manifest.json")).unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    let entries = m["files"].as_array().cloned().unwrap_or_default();+    let entry = entries.iter().find(|e| e["originalPath"] == json!(theirs_s)).cloned().unwrap_or(json!({}));+    check!(+        b,+        "swx_close_all_safely: the manifest records the clean document as CLOSED IN PLACE, with no copy",+        entry["closedInPlace"] == true+            && entry["name"].is_null()+            && entry["recoveryPath"].is_null()+            && entry["recoveryBytes"].is_null(),+        s(&m)+    );+    check!(+        b,+        "swx_close_all_safely: ...with its original's size, and the close it was closed by",+        entry["originalBytes"] == 32_311 && entry["originalModifiedMs"].is_u64() && entry["closed"] == true,+        s(&entry)+    );+    check!(+        b,+        "swx_close_all_safely: ...and no claim to have verified bytes it never wrote",+        entry["verified"].is_null() && entry["preserved"] == true,+        s(&entry)+    );+    let copied = entries.iter().find(|e| e["title"] == "Part2").cloned().unwrap_or(json!({}));+    check!(+        b,+        "swx_close_all_safely: the unsaved document is a COPY, not closed in place",+        copied["closedInPlace"] == false && copied["recoveryPath"].is_string() && copied["verified"] == true,+        s(&copied)+    );++    // THE LISTING.+    let find = |l: &Value| -> Value {+        l["sweeps"]+            .as_array()+            .and_then(|a| a.first())+            .and_then(|sw| sw["closedInPlace"].as_array())+            .and_then(|a| a.iter().find(|e| e["originalPath"] == json!(theirs_s)).cloned())+            .unwrap_or(json!({}))+    };+    let l = b.call("swx_recover_list", json!({}));+    let ip = find(&l);+    check!(+        b,+        "swx_recover_list: lists the document as closed at its own path, untouched",+        ip["closed"] == true+            && ip["present"] == true+            && ip["untouched"] == true+            && ip["bytesNow"] == 32_311+            && ip["howToOpen"].as_str().unwrap_or("").contains("CLOSED IN PLACE, UNTOUCHED")+            && ip["howToOpen"].as_str().unwrap_or("").contains(&theirs_s),+        s(&l)+    );+    let files = l["sweeps"][0]["files"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "swx_recover_list: ...and never as a file in the recovery folder",+        !files.iter().any(|f| f["originalPath"] == json!(theirs_s)) && files.len() == 1,+        s(&l)+    );++    // SOMETHING WRITES IT AFTERWARDS — same length, later mtime.+    let mut rewritten = body.clone();+    rewritten[100] = b'y';+    std::fs::write(&theirs, &rewritten).unwrap();+    std::fs::File::options()+        .write(true)+        .open(&theirs)+        .unwrap()+        .set_modified(mtime_before + std::time::Duration::from_secs(60))+        .unwrap();+    let ip = find(&b.call("swx_recover_list", json!({})));+    check!(+        b,+        "swx_recover_list: a file written since the sweep is NOT called untouched, even at the same size",+        ip["untouched"] == false+            && ip["bytesNow"] == 32_311+            && ip["howToOpen"].as_str().unwrap_or("").contains("CHANGED SINCE"),+        s(&ip)+    );+    // ...and at a different size, it says what the sizes were.+    std::fs::write(&theirs, [&body[..], b"more"].concat()).unwrap();+    let ip = find(&b.call("swx_recover_list", json!({})));+    check!(+        b,+        "swx_recover_list: ...and says what changed",+        ip["untouched"] == false+            && ip["howToOpen"].as_str().unwrap_or("").contains("32311 byte(s) then, 32315 now"),+        s(&ip)+    );+    // AND THEN IT IS GONE.+    std::fs::remove_file(&theirs).unwrap();+    let ip = find(&b.call("swx_recover_list", json!({})));+    check!(+        b,+        "swx_recover_list: a file that is no longer at its path is reported gone, with no copy claimed",+        ip["present"] == false+            && ip["untouched"].is_null()+            && ip["recoveryPath"].is_null()+            && ip["howToOpen"].as_str().unwrap_or("").contains("NOT AT ITS PATH NOW"),+        s(&ip)+    );+}++/// A RECORD WRITTEN BEFORE 0.9.0 has no `closedInPlace` flag, and computed+/// `closed` from the recovery path — which a document closed in place does not+/// have — so it always said false. The listing still finds such a document, and+/// reports its close as UNKNOWN rather than repeating a false that was never+/// evidence. A REFUSED document, which also has no copy, is not one of them.+#[test]+fn a_pre_0_9_record_of_a_clean_document_is_listed_with_its_close_unknown() {+    let scratch = TempDir::new("shutdown-inplace-legacy");+    let work = TempDir::new("shutdown-inplace-legacy-work");+    let theirs = work.join("gearbox.SLDASM");+    std::fs::write(&theirs, vec![b'z'; 5000]).unwrap();+    let refused = work.join("refused.SLDPRT");+    std::fs::write(&refused, vec![b'z'; 4000]).unwrap();+    let dir = scratch.join("recovery").join("20260917-101500");+    std::fs::create_dir_all(&dir).unwrap();+    std::fs::write(+        dir.join("manifest.json"),+        s(&json!({+            "manifestVersion": 1, "sweep": "20260917-101500", "reason": "old sweep",+            "files": [+                {"name": "", "recoveryPath": Value::Null, "title": "gearbox.SLDASM",+                 "originalPath": theirs.to_string_lossy(), "kind": "assembly",+                 "namingFallback": Value::Null, "originalBytes": Value::Null,+                 "recoveryBytes": Value::Null, "signatureOk": Value::Null,+                 "verified": true, "verdictReason": Value::Null, "preserved": true,+                 "closed": false, "wasDirty": false, "wasPathless": false},+                {"name": "", "recoveryPath": Value::Null, "title": "refused.SLDPRT",+                 "originalPath": refused.to_string_lossy(), "kind": "part",+                 "namingFallback": Value::Null, "originalBytes": 4000,+                 "recoveryBytes": 0, "signatureOk": false,+                 "verified": false, "verdictReason": "could not be saved, so it was NOT closed",+                 "preserved": false, "closed": false, "wasDirty": true, "wasPathless": false}+            ]+        })),+    )+    .unwrap();+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[]);+    let l = b.call("swx_recover_list", json!({}));+    let ip = l["sweeps"][0]["closedInPlace"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "swx_recover_list: finds the clean document an old record describes, and only that one",+        ip.len() == 1 && ip[0]["originalPath"] == json!(theirs.to_string_lossy()),+        s(&l)+    );+    check!(+        b,+        "swx_recover_list: ...with its close UNKNOWN, not the false an old record wrote",+        ip[0]["closed"].is_null()+            && ip[0]["present"] == true+            && ip[0]["untouched"].is_null()+            && ip[0]["howToOpen"].as_str().unwrap_or("").contains("older than 0.9.0"),+        s(&ip[0])+    );+}
tests/wiring.rs+772
@@ -53,6 +53,8 @@ const ENTRY_POINTS: &[&str] = &[     "src/launch.rs",     "src/comhost/com.rs",     "src/comhost/host.rs",+    // Startup decides where the bridge's data lives and moves it there.+    "src/server.rs", ];  /// `(function name, why it exists)` — the why is printed when it has no caller,@@ -88,6 +90,44 @@ const GUARDS: &[(&str, &str)] = &[      "the allowlist: nothing outside it is ever terminated"),     ("attributed_chain",      "no handle, no kill — the chain comes from the invoke or from nothing"),+    ("blocking_dialog_now",+     "the safe-close sweep stops at a dialog and never answers one"),+    ("unique_recovery_path",+     "two open documents sharing a title must not share a recovery file"),+    ("recovery_dir",+     "every sweep preserves into its own folder, so nothing overwrites a previous rescue"),+    ("dissolve_refusal",+     "an unmeasured link-breaking API is refused on EVERY route, never silently dropped"),+    ("register_created",+     "a document this bridge opened is owned at birth — the drain has no other key when it is pathless"),+    ("unknown_kind_floor",+     "a document whose kind nobody could establish takes the STRICTEST floor"),+    ("write_manifest",+     "the sweep records what it decided beside the files it wrote"),+    ("reverified_entry",+     "a folder with no manifest is re-checked and reported as weaker evidence, not as the same"),+    ("how_to_open",+     "a file the sweep refused is never offered to the user as recovered work"),+    ("with_data",+     "a COM failure reaches the caller structured, not flattened into prose"),+    ("host_failure_entry",+     "a host failure inside the sweep keeps its structure — it becomes a permanent manifest record"),+    ("recovery_file_verdict",+     "a document is closed only against a file proved to BE a SOLIDWORKS document"),+    ("free_bytes",+     "a sweep that cannot fit on the volume must not start writing"),+    ("from_type_name",+     "the document's KIND comes from the backend, never re-derived from the name it produced"),+    ("estimate_writes_typed",+     "how much this sweep is about to write, by document KIND, before it writes any of it"),+    ("refuse_inside_install",+     "a recovery folder is never placed where an update deletes it (SEV-1, 2026-09-30)"),+    ("migrate_legacy",+     "data an older bridge left in ab's install cache is copied out before anything reads the data root"),+    ("legacy_root",+     "the listing still reads the install cache older bridges wrote their rescues to"),+    ("closed_in_place_entry",+     "a document closed at its own path is listed, with whether that file is still untouched"), ];  /// Source files that are PRODUCTION code. A call from inside a `mod tests` block@@ -198,3 +238,735 @@ fn every_guard_has_a_production_caller() {         unwired.join("\n")     ); }++/// THE RECOVERY FILE MUST BE VERIFIED WHERE THE DECISION IS MADE — in BOTH+/// backends, through the SAME function.+///+/// `recovery_file_verdict` is the whole premise for every close in the sweep: a+/// file that exists, or even one that is large, is not necessarily the document.+/// The rule was checkable in the simulator and could have quietly gone missing+/// from the real backend — the exact shape of the five unwired guards this+/// project has already shipped. A backend with its own private version of the+/// check is the same defect wearing a disguise, so this looks for the shared+/// function by name.+#[test]+fn the_recovery_file_is_verified_in_both_backends() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/comhost");+    for file in ["com.rs", "mock.rs"] {+        let text = strip_test_modules(+            &std::fs::read_to_string(root.join(file)).expect("comhost source"),+        );+        let applied = text.lines().any(|l| {+            let t = l.trim_start();+            !t.starts_with("//") && l.contains("recovery_file_verdict(")+        });+        assert!(+            applied,+            "{file} never calls recovery_file_verdict.\n\n\+             The backend decides there whether a document may be closed. Without the \+             shared verdict the decision falls back to `exists()` or to a private size \+             test — and a document whose only copy was in memory gets closed on the \+             strength of an empty, truncated, or foreign file. If this rule genuinely \+             moved, move this test with it."+        );+    }+}++/// AND IT MUST BE ASKED AGAIN AT THE MOMENT OF THE CLOSE.+///+/// A file verified at save time and closed against several seconds later is a+/// window, and the disk can fill inside it. Both backends re-verify in the close+/// phase for any document this sweep WROTE.+#[test]+fn the_close_phase_re_verifies_what_the_sweep_wrote() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/comhost");+    for file in ["com.rs", "mock.rs"] {+        let text = strip_test_modules(+            &std::fs::read_to_string(root.join(file)).expect("comhost source"),+        );+        assert!(+            text.contains("written_here") && text.matches("recovery_file_verdict(").count() >= 2,+            "{file} verifies the recovery file once, at save time. The close happens \+             later, and the disk can fill in between — re-verify in the close phase for \+             any document this sweep wrote."+        );+    }+}++/// THE KIND MUST NOT BE READ BACK OFF THE NAME IT PRODUCED.+///+/// **THIS IS DOCUMENTATION, NOT A GUARD.** It greps for the SHAPE of the pass-26+/// mistake, and a one-line alias — or a variable holding the path under another+/// name — walks straight past it. It is here so that someone editing the naming+/// code meets the story before they reintroduce the bug, not because it can stop+/// them. The guards are the behavioural tests: `a_document_nothing_can_classify_+/// is_never_called_a_part`, `an_unreadable_type_falls_back_to_the_documents_own_+/// path`, and adversary25's assembly-held-to-the-part-floor case. If this test+/// and those ever disagree, believe those.+///+/// It has already produced one false positive, in 0.7.6, when the naming chain+/// legitimately began consulting the document's own `GetPathName`. That is+/// evidence about the document, not a name this sweep produced — but the+/// identifier is spelled `path` either way.+///+/// The recovery file's extension is CHOSEN FROM the document's type, so+/// classifying the file by that extension is asking the question to its own+/// answer — one unreadable `GetType` named a truncated assembly `.SLDPRT` and the+/// part floor then passed it. The kind must travel with the document. This looks+/// for the shape of the mistake rather than waiting for it to be re-introduced by+/// someone who did not read the story.+#[test]+fn no_backend_classifies_a_recovery_file_by_the_name_it_chose() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");+    for file in ["comhost/com.rs", "comhost/mock.rs", "comhost/host.rs"] {+        let text = strip_test_modules(+            &std::fs::read_to_string(root.join(file)).expect("source"),+        );+        for (i, line) in text.lines().enumerate() {+            let t = line.trim_start();+            if t.starts_with("//") {+                continue;+            }+            let derives = line.contains("from_hint(") || line.contains("from_extension(");+            // ONLY THE NAMES THIS SWEEP UNAMBIGUOUSLY CHOSE. `path` is+            // deliberately NOT in this list: in the preserve phase it is+            // `GetPathName` — the document's own file, legitimate evidence — and+            // in the close phase it is the recovery key. One identifier, two+            // provenances, and no grep can tell them apart. That ambiguity is the+            // clearest statement of why this test is documentation and the+            // behavioural tests are the guard.+            let from_a_chosen_name = line.contains("&out")+                || line.contains("now_path")+                || line.contains("recoveryPath");+            assert!(+                !(derives && from_a_chosen_name),+                "{file}:{} derives a document's KIND from a path this sweep chose:\n  {}\n\n\+                 The recovery file's extension comes FROM the kind, so reading the kind back out \+                 of it cannot check anything — an unreadable GetType names the file .SLDPRT and \+                 the part floor then accepts a truncated assembly. Carry the kind with the \+                 document instead. (Classifying a TITLE the user wrote is fine; classifying a \+                 name this code produced is not.)",+                i + 1,+                line.trim()+            );+        }+    }+}++/// "NOTHING" AND "OMITTED" MUST NOT BE CONFUSED AGAIN.+///+/// **This is documentation, not a guard** — the same caveat as the grep pin+/// above, and for the same reason: a local alias walks past it. The real guards+/// are the Windows-only unit test in `variant.rs`, which inspects the VARIANT+/// itself, and a live import.+///+/// It is here because this bridge passed `VT_EMPTY` for `LoadFile4`'s+/// `importData` from v0.2.0 until 2026-09-17 and nobody could see it: the COM+/// failure carrying `Type mismatch. (0x80020005) (argument index 1)` was being+/// dropped one frame above where it was made. `LoadFile4` had probably never+/// worked live. The simulator cannot catch this — it has no VARIANTs — so the+/// only thing standing between the next reader and the same mistake is that they+/// meet the story.+#[test]+fn an_omitted_object_argument_is_spelled_nothing_not_empty() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/comhost");+    let com = strip_test_modules(&std::fs::read_to_string(root.join("com.rs")).expect("com.rs"));++    // LoadFile4's importData.+    let load4 = com+        .split("\"LoadFile4\",")+        .nth(1)+        .and_then(|rest| rest.split(");").next())+        .unwrap_or("")+        .to_string();+    assert!(+        !load4.is_empty(),+        "the LoadFile4 call site could not be found — if it moved, move this test with it"+    );+    // THE ARGUMENT MAY BE BUILT ABOVE THE CALL — 0.8.1 does exactly that, so the+    // matrix can pass a real ImportFileData object instead — so look at the+    // construction as well as the call site. (A grep that only understood one+    // spelling produced its second false positive here; that is what this test+    // being documentation rather than a guard means in practice.)+    let data_arg_default = com.contains("None => Var::null_dispatch(),");+    assert!(+        load4.contains("null_dispatch()") || data_arg_default,+        "LoadFile4's importData is an [in] OBJECT parameter, and this bridge must pass Nothing \\+         (VT_DISPATCH with a null pdispVal), not VT_EMPTY. Live 2026-09-17, VT_EMPTY answered \\+         `Type mismatch. (0x80020005) (argument index 1)` — with rgvarg packed reversed, that is \\+         this argument exactly. Call site:\\n{load4}"+    );+    assert!(+        !load4.contains("Var::empty()") && !com.contains("None => Var::empty(),"),+        "LoadFile4 is passing an EMPTY variant again. Nothing and omitted are different things to \\+         IDispatch, and this one cost every import for as long as the bridge has existed:\\n{load4}"+    );++    // And the escape hatch's `null` literal, whose documented use — SelectByID2's+    // callout — is an [in] object parameter of exactly the same kind.+    assert!(+        com.contains("Literal::Null => Var::null_dispatch()"),+        "swx_com_eval's `null` literal must also be a null DISPATCH: its documented use is \\+         SelectByID2's callout argument, an [in] object parameter like LoadFile4's importData."+    );+}++/// THE IMPORT DIAGNOSTICS DIALOG IS NEVER ANSWERED, and that is a classification+/// fact rather than a policy comment.+///+/// `swImportAutoRunImportDiagnostics` is TRUE on this install, so a STEP with+/// faulty faces raises this MODAL dialog during an import. The choice it offers —+/// attempt to heal the faces, or not — changes the geometry the user ends up+/// with, so it belongs to them. A mutation flipping this kind from never-answer+/// to answerable broke nothing before this test existed.+#[test]+fn the_import_diagnostics_dialog_is_classified_and_never_answered() {+    for title in [+        "Import Diagnostics",+        "SOLIDWORKS Import Diagnostics",+        "import diagnostics",+    ] {+        let (kind, blocking, never_answer) = swx_bridge::abwin::classify(title);+        assert_eq!(kind, "import_diagnostics", "{title:?} must be recognised, not `unknown`");+        assert!(blocking, "{title:?} is modal — an import waits behind it");+        assert!(+            never_answer,+            "{title:?} must be NEVER-ANSWERED. Its buttons decide whether SOLIDWORKS alters the \+             geometry of the user's imported body; that is not a decision a bridge makes on \+             somebody's behalf, and the import reports `stoppedByDialog` so a person can."+        );+    }+}++/// THE GATES' FORMAT STRINGS MUST BE VALID, because a gate that crashes on its+/// own message is worse than a gate that fails.+///+/// Live, 2026-09-17: `NOTES.append("%TEMP%\\swx-drain-gate still holds files …" % …)`+/// raised `ValueError: unsupported format character 'T'` at the LAST line of a+/// run whose three real claims had all passed. It crashed before the verdict was+/// printed and before cleanup ran, so the gate reported failure and left its own+/// scratch directory behind — in a message about leaving a directory behind.+///+/// THIS USES PYTHON'S OWN PARSER, and the first version of this test did not.+/// A line-based grep missed the actual bug, because the `%` operator sits on the+/// NEXT line of an implicitly concatenated literal — it passed with the defect+/// restored, which makes it worse than nothing. Anything that checks somebody+/// else's language has to use that language's parser.+///+/// WHAT THIS DOES NOT COVER: unbound locals. The same gate also crashed on a name+/// assigned in one branch and read in another (`twin_name`, 0.8.4), which is the+/// same shape of failure — a crash in cleanup after every real claim passed. A+/// heuristic for that flagged `try`/`except` bindings and both-branch assignments+/// as well, and a test that cries wolf gets disabled, so it is not shipped here.+/// `pyflakes` does this correctly and is not installed in this container; the+/// standing practice instead is to give every cross-branch variable a value+/// before the branch.+#[test]+fn every_gate_format_string_is_valid() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let checker = r#"+import ast, re, sys, pathlib+BAD = re.compile(r'%(?![-#0 +]*\d*(?:\.\d+)?[hlL]?[diouxXeEfFgGcrsa%])')+bad = []+n = 0++for f in sorted(pathlib.Path(sys.argv[1]).glob('*.py')):+    tree = ast.parse(f.read_text())+    for node in ast.walk(tree):+        if (isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod)+                and isinstance(node.left, ast.Constant) and isinstance(node.left.value, str)):+            n += 1+            m = BAD.search(node.left.value)+            if m:+                bad.append('%s:%d: %%%s is not a conversion -- %r'+                           % (f.name, node.lineno, node.left.value[m.end():m.end()+1],+                              node.left.value[max(0, m.start()-30):m.start()+40]))+print('CHECKED %d' % n)+for b in bad:+    print('BAD ' + b)+"#;+    let script = root.join("target").join("gate-format-check.py");+    let _ = std::fs::create_dir_all(root.join("target"));+    if std::fs::write(&script, checker).is_err() {+        return;+    }+    let out = match std::process::Command::new("python3")+        .arg(&script)+        .arg(root.join("tools"))+        .output()+    {+        Ok(o) => o,+        // No python3 here: the gates are Python, so this check belongs wherever+        // Python is. Silence beats a false pass, but a missing interpreter is+        // not a defect in the gates.+        Err(_) => return,+    };+    let text = String::from_utf8_lossy(&out.stdout).to_string();+    let checked: usize = text+        .lines()+        .find_map(|l| l.strip_prefix("CHECKED "))+        .and_then(|n| n.trim().parse().ok())+        .unwrap_or(0);+    assert!(+        checked > 0,+        "no %-format expressions were found in tools/ — the checker did not run:\n{}{}",+        text,+        String::from_utf8_lossy(&out.stderr)+    );+    let bad: Vec<&str> = text.lines().filter(|l| l.starts_with("BAD ")).collect();+    assert!(+        bad.is_empty(),+        "a gate %-formats a string containing a literal `%` that is not a conversion. Python \+         raises ValueError the moment that branch runs — which is how a gate crashed at its last \+         line, after every real claim had passed, before printing its verdict and before cleaning \+         up after itself. Use an f-string, or write `%%`:\n  {}",+        bad.join("\n  ")+    );+}++/// DOES THIS SENTENCE ASK THE USER TO CLOSE DOCUMENTS?+///+/// One rule, evaluated — not a pile of phrases that happen to make this+/// project's own prose pass. Exemptions are for CATEGORIES of thing that are+/// genuinely the user's (answering a dialog, restarting their session, looking+/// at their screen), never for the text of a sentence somebody wrote.+/// WHICH FILES DESCRIBE THE RULE, AND WHICH ARE GOVERNED BY IT.+///+/// THE DEFAULT IS GOVERNED, and it took three tries to get there. 0.8.7 stripped+/// quoted text everywhere so a changelog could record the sentences this rule+/// catches, which blinded SKILL.md. 0.8.8 made it per-file — SKILL.md verbatim,+/// every other markdown stripped — and that still leaked twice: a blockquoted ask+/// in `docs/RUNBOOK.md` slipped, because a runbook INSTRUCTS even though it lives+/// under `docs/`, and `skills/**/SKILL.md` was not scanned at all.+///+/// Both leaks are the same shape: a rule stated as "these files are governed",+/// which is wrong by omission the moment a file is added. So it is INVERTED, the+/// way every other default in this project already runs — an unknown document+/// kind takes the strictest floor, an unreadable census is not permission, an+/// unverifiable destination is not free:+///+///   * EVERY `.md`, `.rs` and `.py` in the repository is scanned, and NOTHING is+///     stripped from it;+///   * a short ALLOWLIST of files that DESCRIBES this bridge rather than driving+///     it — the README and the named reports under `docs/` — has quoted and code+///     spans stripped, because their changelogs and post-mortems quote the very+///     sentences the rule catches;+///   * a NEW FILE IS GOVERNED until somebody adds it to that list on purpose.+fn describes_rather_than_instructs(root: &Path, path: &std::path::Path) -> bool {+    let rel = path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/");+    // NAMED, not pattern-matched: `docs/` also holds runbooks, and a runbook is+    // an instruction that happens to live in a directory called docs.+    const DESCRIBES: &[&str] = &[+        "README.md",+        "docs/QUIT-INVESTIGATION.md",+        "docs/LAUNCH-RESILIENCE.md",+        "docs/IMPORT-LEAK.md",+        "docs/ECOSYSTEM-TABLE.md",+        "docs/crate-build-log.md",+    ];+    DESCRIBES.contains(&rel.as_str())+}++/// STRIP THE PARTS OF A MARKDOWN STATEMENT THAT ARE QUOTING, NOT INSTRUCTING.+///+/// A changelog that records *"Ask the user to close the remaining documents"* as+/// a bypass the pin must catch is documentation OF the rule, not a violation of+/// it — and the fix for that is never to add an exemption phrase, nor to edit the+/// changelog into vagueness so a test passes. Example violations belong in+/// quotes, in code spans, and in the `cases` table; the rule reads what is left.+///+/// This is a category (quoted text), not a phrase, which is the distinction the+/// eight deleted exemptions failed.+fn strip_quotations(statement: &str) -> String {+    let mut out = String::new();+    let mut quoted = false;   // inside "…" or “…”+    let mut code = false;     // inside `…`+    for c in statement.chars() {+        match c {+            '`' => {+                code = !code;+                continue;+            }+            '"' => {+                quoted = !quoted;+                continue;+            }+            '\u{201C}' => {+                quoted = true;+                continue;+            }+            '\u{201D}' => {+                quoted = false;+                continue;+            }+            _ => {}+        }+        if !quoted && !code {+            out.push(c);+        }+    }+    out+}++/// DOES THIS STATEMENT ASK THE USER TO CLOSE DOCUMENTS?+///+/// One rule, evaluated — not a pile of phrases that happen to make this project's+/// own prose pass. Exemptions are for CATEGORIES (quoting rather than+/// instructing; a decision that is genuinely the user's), never for the text of a+/// sentence somebody wrote.+fn asks_to_close(sentence: &str) -> bool {+    let l = sentence.to_lowercase();+    const ASKS: &[&str] = &["ask drew", "ask the user", "ask them", "asks the user",+                            "asks drew", "asks them"];+    const CLOSE_REQUESTS: &[&str] = &[+        "to close", "close them", "close the document", "close the remaining",+        "close the leftover", "close every", "close it", "close their",+    ];+    // THE PROHIBITION MAY NAME WHAT IT FORBIDS. "it never asks the user to close"+    // states the rule; so does "do not ask the user to close".+    if l.contains("never ask") || l.contains("do not ask") || l.contains("don't ask")+        || l.contains("despise")+    {+        return false;+    }+    let ask_at: Vec<usize> = ASKS+        .iter()+        .flat_map(|a| l.match_indices(a).map(|(i, m)| i + m.len()).collect::<Vec<_>>())+        .collect();+    if ask_at.is_empty() {+        return false;+    }+    let close_at: Vec<usize> =+        CLOSE_REQUESTS.iter().flat_map(|c| l.match_indices(c).map(|(i, _)| i).collect::<Vec<_>>()).collect();+    if close_at.is_empty() {+        return false;+    }+    // ORDER-INDEPENDENT, WITHIN ONE STATEMENT. The ask and the close have to be+    // the same request, but English puts them in either order: "ask the user to+    // close them" and "Close the documents (ask the user)" are the same+    // instruction. Requiring close-after-ask let the second through. What the+    // window still excludes is two unrelated clauses far apart — "close the+    // bridge's own documents, then ask the user to restart" has no close request+    // near the ask at all.+    const WINDOW: usize = 70;+    for a in &ask_at {+        for c in &close_at {+            let near = if c >= a { c - a <= WINDOW } else { a - c <= WINDOW };+            if near {+                return true;+            }+        }+    }+    false+}++/// THE RULE ABOVE, PINNED BY EXAMPLES — the way `classify_control` is.+///+/// The pin acquired eight phrase-shaped exemptions so it would pass over this+/// project's own commentary, and two of them were working bypasses: `"passed."`+/// exempted *"The sweep passed. Ask the user to close the remaining documents."*+/// and `"restart"` exempted *"after the restart, ask the user to close the+/// leftover documents"*. Both are planted here as MUST-CATCH cases, beside the+/// legitimate sentences each exemption was actually added for. A future+/// exemption has to keep every row true, which makes it provably safe rather+/// than provably convenient.+#[test]+fn the_never_ask_rule_is_pinned_by_examples() {+    // (sentence, must this be flagged?)+    let cases: &[(&str, bool)] = &[+        // ── THE ADVERSARY'S FIVE, from passes 35 and 36. Four must be caught+        //    and one must not, and between them they pin both the phrase+        //    exemptions that were deleted and the DIRECTION the rule used to+        //    assume.+        ("The sweep passed. Ask the user to close the remaining documents.", true),+        ("After the restart, ask the user to close the leftover documents.", true),+        ("Ask the user to run the sweep, which will close the documents.", true),+        // THE MIRROR ORDER: an imperative may lead with its object. Requiring+        // close-after-ask let this through, and it is the same instruction.+        ("Close the documents (ask the user).", true),+        // ...while the rule STATING itself, with the same two words in the same+        // sentence, must still be allowed.+        ("The bridge closes; it never asks the user to close.", false),+        // ── PLAIN VIOLATIONS.+        ("Ask Drew to close them himself.", true),+        ("ask the user to close them in SOLIDWORKS (File > Close)", true),+        ("A dialog is up, so ask the user to close the documents by hand.", true),+        ("If that fails, ask the user to close every open document and retry.", true),+        // ── LEGITIMATE: the decision really is theirs.+        ("This bridge never answers one — show the user a screenshot and let them answer.", false),+        ("A dialog appeared; ask the user to answer it, then run this again.", false),+        ("ask the user to dismiss the dialog and retry", false),+        ("close the bridge's own documents, then ASK THE USER to restart", false),+        ("ask the user to look at their screen", false),+        // ── THE RULE NAMING ITSELF.+        ("NEVER ask the user to close documents: this bridge closes them.", false),+        ("Do not ask the user to close anything — swx_close_all_safely does it.", false),+        // ── DESCRIPTION, NOT A REQUEST.+        ("if a document of theirs has closed, it was NOT saved by this bridge", false),+        // ── UNRELATED.+        ("Ask the user which configuration they want.", false),+        ("swx_close_all_safely preserves every document to recovery before closing.", false),+    ];+    for (sentence, expected) in cases {+        assert_eq!(+            asks_to_close(sentence),+            *expected,+            "the never-ask rule got this wrong: {sentence:?} (expected flagged={expected})"+        );+    }++    // QUOTING IS NOT INSTRUCTING — and the same sentence unquoted still is.+    //+    // The changelog entry for pass 35 records both bypass sentences as examples+    // of what this rule must catch, and it broke master: the pin read them as+    // violations. The fix is not an exemption phrase, and it is certainly not+    // editing the changelog until it stops saying what was fixed. Quoted text,+    // code spans and blockquotes are examples; everything else is prose.+    let quoted = r#"two were working bypasses — "The sweep passed. Ask the user to close the remaining documents." and "After the restart, ask the user to close the leftover documents." both slipped through."#;+    assert!(+        !asks_to_close(&strip_quotations(quoted)),+        "a changelog QUOTING the sentences this rule catches was read as committing them. \+         Documentation of a rule is not a violation of it:\n  {quoted}"+    );+    let unquoted = "two were working bypasses: the sweep passed, ask the user to close the \+                    remaining documents, and that slipped through.";+    assert!(+        asks_to_close(&strip_quotations(unquoted)),+        "stripping quotations must not disarm the rule for UNQUOTED prose — the same words \+         outside quotes are still an instruction:\n  {unquoted}"+    );+    // A CODE SPAN AND A BLOCKQUOTE ARE THE SAME CATEGORY.+    assert!(+        !asks_to_close(&strip_quotations("the old hint said `ask the user to close them` and is gone")),+        "an inline code span holds an example, not an instruction"+    );+}++/// NOTHING IN THIS PROJECT ASKS THE USER TO CLOSE DOCUMENTS.+///+/// Drew, 2026-09-11: *"I despise every time you ask for help closing. You MUST+/// find/build a way to safely close the docs/program."*+///+/// The sentence survived in `tools/quit-gate.py` a week after the sweep shipped,+/// which is why this exists — and this test has since had three defects of its+/// own, each found by review rather than by use: it missed the repo-root+/// `SKILL.md`/`README.md` that an agent actually loads; it excused any line+/// containing the word "dialog"; and it grew phrase exemptions that let two real+/// violations through. The rule now lives in `asks_to_close`, pinned by examples+/// above.+///+/// A LESSON FROM THE ADVERSARY'S OWN PASS, recorded because it generalises:+/// their scan matched `contains("glob")` and hit `global`. Substring tests on+/// somebody else's text find things that are not there. Where a parser exists,+/// use it — `every_gate_format_string_is_valid` uses Python's `ast` for exactly+/// this reason — and where one does not, pin the rule with examples.+#[test]+fn nothing_asks_the_user_to_close_documents() {+    let root = Path::new(env!("CARGO_MANIFEST_DIR"));+    let mut offenders: Vec<String> = Vec::new();+    // EVERY FILE IN THE REPOSITORY THAT SOMEBODY MIGHT FOLLOW.+    //+    // SKILL.md is scanned with NOTHING STRIPPED — the root one and every+    // skills/**/ one — because a blockquoted or backticked instruction in the+    // file an agent follows is still an instruction, and two earlier versions of+    // this pin got that wrong. It is no longer a named special case: being+    // governed is the DEFAULT now, and only the short allowlist of files that+    // DESCRIBE has anything stripped (see `describes_rather_than_instructs`).+    //+    // The walk used to be "SKILL.md, README.md, and the src/tools/docs trees",+    // which missed the skills tree entirely and treated everything under `docs/`+    // as description — including a runbook, which instructs. It is the whole repo+    // now, so a file nobody thought about is governed rather than invisible.+    let mut files: Vec<std::path::PathBuf> = Vec::new();+    let mut stack = vec![root.to_path_buf()];+    while let Some(dir) = stack.pop() {+        let Ok(entries) = std::fs::read_dir(&dir) else { continue };+        for e in entries.flatten() {+            let p = e.path();+            let name = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+            if p.is_dir() {+                // NOT THINGS ANYBODY FOLLOWS: build output, version control, and+                // the test tree — which states the forbidden sentences as DATA+                // (this file's own `cases` table, and every adversary report that+                // quotes a finding). A test asserting that a sentence must be+                // caught is the opposite of committing it.+                if !matches!(name.as_str(), "target" | ".git" | "node_modules" | "tests") {+                    stack.push(p);+                }+            } else if p+                .extension()+                .map(|x| matches!(x.to_string_lossy().as_ref(), "rs" | "py" | "md"))+                .unwrap_or(false)+            {+                files.push(p);+            }+        }+    }+    for p in files {+        let Ok(text) = std::fs::read_to_string(&p) else { continue };+        let is_markdown = p.extension().map(|x| x == "md").unwrap_or(false);+        // GOVERNED BY DEFAULT. Only the named describing files have their quoted+        // and code spans stripped; everything else is read exactly as written,+        // because a quoted instruction in a file somebody follows is still an+        // instruction.+        let strip = describes_rather_than_instructs(root, &p);+        let mut joined: Vec<(usize, String)> = Vec::new();+        if is_markdown {+            // MARKDOWN PROSE WRAPS WITH NO MARKER, so a paragraph is the unit —+            // a SKILL.md sentence hid across two lines once. A LIST ITEM or a+            // TABLE ROW is its own statement though: joining a whole bullet list+            // into one paragraph puts every ask beside every close.+            let mut para = String::new();+            let mut first = 0usize;+            for (n, line) in text.lines().enumerate() {+                let t = line.trim_start();+                // A BLOCKQUOTE IS QUOTING — in a file that describes. In+                // SKILL.md it is an instruction with a decoration on it, and+                // skipping it is how two real violations hid.+                if strip && (t.starts_with('>') || t.starts_with("```")+                    || t.starts_with("    "))+                {+                    continue;+                }+                if t.starts_with("- ") || t.starts_with("* ") || t.starts_with('|') {+                    if !para.is_empty() {+                        joined.push((first, std::mem::take(&mut para)));+                    }+                    joined.push((n + 1, line.trim().to_string()));+                    continue;+                }+                if line.trim().is_empty() {+                    if !para.is_empty() {+                        joined.push((first, std::mem::take(&mut para)));+                    }+                    continue;+                }+                if para.is_empty() {+                    first = n + 1;+                }+                para.push(' ');+                para.push_str(line.trim());+            }+            if !para.is_empty() {+                joined.push((first, para));+            }+        } else {+            // A RUST STRING BROKEN WITH `\` IS ONE SENTENCE.+            let mut current = String::new();+            let mut start_line = 0usize;+            for (n, line) in text.lines().enumerate() {+                let t = line.trim_end();+                if current.is_empty() {+                    start_line = n + 1;+                }+                current.push(' ');+                current.push_str(t.trim_start().trim_end_matches('\\'));+                if t.ends_with('\\') {+                    continue;+                }+                joined.push((start_line, std::mem::take(&mut current)));+            }+            if !current.is_empty() {+                joined.push((start_line, current));+            }+        }+        for (n, stmt) in joined {+            // IN MARKDOWN, QUOTED TEXT IS AN EXAMPLE, NOT AN INSTRUCTION — the+            // changelog entry recording the two bypass sentences is documentation+            // OF this rule. In source files the quotes ARE the hints, so nothing+            // is stripped there.+            let stmt = if strip { strip_quotations(&stmt) } else { stmt.clone() };+            if asks_to_close(&stmt) {+                offenders.push(format!(+                    "{}:{}: {}",+                    p.strip_prefix(root).unwrap_or(&p).to_string_lossy(),+                    n,+                    stmt.trim()+                ));+            }+        }+    }+    assert!(+        offenders.is_empty(),+        "something here asks the user to close documents. The bridge closes them itself — \+         swx_close_all_safely preserves every document to recovery, verifies it, and only then \+         closes; anything it cannot preserve stays open and is named. Say that instead:\n  {}",+        offenders.join("\n  ")+    );+}++/// A WIN32 CHECKBOX IS CLASS "Button", AND THAT NEARLY COST A DISTINCTION.+///+/// `BS_CHECKBOX` (0x2) and `BS_AUTOCHECKBOX` (0x3) are STYLE bits, not classes,+/// so a control tree read by class alone files "Don't show this again" as a+/// button. `acknowledgeOnly` counts buttons — so a licence dialog carrying OK+/// plus a do-not-show-again box would have read as TWO choices and stopped being+/// acknowledge-only, and a dialog with only a checkbox would have read as one.+///+/// This runs on raw `(class, style, text)` tuples, because the simulator hands+/// back pre-classified lists and a test against those proves nothing about the+/// classifier. It is also the only place this logic can be exercised on Linux,+/// since the reader itself is `cfg(windows)`.+#[test]+fn a_checkbox_is_not_counted_as_a_button() {+    use swx_bridge::launchos::classify_control;+    // (class, style, text, expected)+    let cases: &[(&str, u32, &str, Option<&str>)] = &[+        ("Button", 0x50010000, "OK", Some("button")),+        ("Button", 0x50010000 | 0x2, "Don't show this again", Some("checkbox")),+        ("Button", 0x50010000 | 0x3, "Remember my choice", Some("checkbox")),+        ("Button", 0x50010000 | 0x4, "Save all", Some("radio")),+        ("Button", 0x50010000 | 0x9, "Discard", Some("radio")),+        ("Static", 0x50000000, "Unable to connect to server.", Some("text")),+        ("Edit", 0x50000000, "C:\\work\\bracket.SLDPRT", Some("text")),+        // Nothing to report: no text, or a control this bridge does not read.+        ("Button", 0x50010000, "   ", None),+        ("SysListView32", 0x50000000, "rows", None),+    ];+    for (class, style, text, expected) in cases {+        assert_eq!(+            classify_control(class, *style, text),+            *expected,+            "({class:?}, style 0x{style:X}, {text:?}) classified wrongly — a checkbox counted as \+             a button changes whether a dialog looks acknowledge-only, and that field exists to \+             inform a decision about what may ever be pressed"+        );+    }+}++/// ACKNOWLEDGE-ONLY MEANS EXACTLY ONE BUTTON, AND IT ACKNOWLEDGES.+///+/// A rule of `len() >= 1` would call `["OK", "Cancel"]` acknowledge-only, and+/// those two buttons decide opposite things. The field is being used to inform a+/// policy decision about what this bridge may ever press, so it has to be exact —+/// and `neverAnswer` stays true either way.+#[test]+fn acknowledge_only_is_exactly_one_acknowledgement() {+    use swx_bridge::launchos::DialogContents;+    let d = |buttons: &[&str], checkboxes: &[&str]| DialogContents {+        text: vec![],+        buttons: buttons.iter().map(|s| s.to_string()).collect(),+        checkboxes: checkboxes.iter().map(|s| s.to_string()).collect(),+        truncated: false,+    };+    assert!(d(&["OK"], &[]).acknowledge_only(), "one OK is an acknowledgement");+    assert!(d(&["&OK"], &[]).acknowledge_only(), "the accelerator ampersand is not part of it");+    assert!(d(&["Close"], &[]).acknowledge_only(), "so is a lone Close");+    // A CHECKBOX BESIDE IT IS NOT A CHOICE ABOUT THE USER'S WORK.+    assert!(+        d(&["OK"], &["Don't show this again"]).acknowledge_only(),+        "a do-not-show-again box does not turn an acknowledgement into a decision"+    );+    for buttons in [+        vec!["OK", "Cancel"],+        vec!["Yes", "No"],+        vec!["Save all to This PC", "Don't Save", "Cancel"],+        vec![],+    ] {+        assert!(+            !d(&buttons, &[]).acknowledge_only(),+            "{buttons:?} is not acknowledge-only — every one of those decides something different \+             about the user's work"+        );+    }+}
tools/drain-gate.py+806−129
@@ -44,10 +44,44 @@ THE EXPERIMENT, on documents the bridge makes itself                    removed. If cleanup cannot finish it says so LOUDLY rather                    than leaving the user to find the mess. -    tools/drain-gate.py                 # the gate+    tools/drain-gate.py                 # the gate (STEP fixture)+    tools/drain-gate.py --fixture new   # same proof without importing anything     tools/drain-gate.py --baseline      # step 1 only. Reads. Creates nothing.     tools/drain-gate.py --keep          # skip step 6 (leave the evidence open) +WHY `--fixture new` EXISTS, and what it proves instead+──────────────────────────────────────────────────────+Measured 2026-09-17 on SOLIDWORKS Connected 2026: with+`swMultiCAD_Enable3DInterconnect` TRUE — Drew's setting, which this bridge reads+and never changes — there is NO programmatic neutral-file import route at all+(LoadFile4 errors=1 with Nothing and with an ImportFileData object, LoadFile2+false, OpenDoc6 swFileRequiresRepairError). So the STEP fixture cannot create+anything to drain on that machine, and a gate that cannot create its own evidence+cannot run.++MEASURED LIVE, 2026-09-17: SOLIDWORKS REFUSED to open a second document with the+same title from another directory — `errors = 65536`+(`swFileWithSameTitleAlreadyOpen`). So a title collision between SAVED documents+is IMPOSSIBLE on this edition, and only PATHLESS documents can ever collide —+which is precisely the state identity-at-birth removes, and it means the drain's+collision guard protects a case that can only arise before a document is given a+file.++`--fixture new` uses `swx_new_document` twice instead. THE OWNERSHIP PROOF IS THE+REGISTRY, NOT THE VERB THAT MADE THE DOCUMENT: both routes go through the same+identity-at-birth and `register_created`, so the same ownership question is asked+of the same registry.++What changes is WHICH DRAIN VERB the gate can use, and this is a real difference+worth stating rather than papering over: `swx_close_import_temps` identifies its+victims BY PATH — a `...\\Temp\\swx<pid>\\...\\IC~~\\...` conversion temp — and that is+deliberate, because the path is the ownership proof that survives a pid change+(it is what made the 1342-temp leak drainable at all). A `new_document` part has+no such path and is CORRECTLY not drained by it. So the `new` fixture drains with+`swx_reset_workspace`, which closes documents the bridge created according to the+ownership REGISTRY. The same-titled-collision half is unchanged: a saved twin+still shares a title with a bridge-created document, and must still survive.+ EXIT CODES     0  the premise holds and every claim held     1  a claim did not hold@@ -55,12 +89,17 @@ EXIT CODES     3  a probe could not be READ — unproven, never a pass """ import argparse+import atexit+import os+import time import base64 import json import subprocess import sys -BRIDGE = "http://127.0.0.1:24695/command"+# THE ROUTE: verbs go through adom-bridge's verb routing to the bridge it hosts+# (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" FIXTURE_STEM = "DrainGate" @@ -76,7 +115,7 @@ class Refused(Exception):     """The preconditions were not met. Nothing was created or closed."""  -def ps(script, reason, timeout=120):+def ps(script, reason, timeout=120, _retry=False):     enc = base64.b64encode(script.encode("utf-16le")).decode()     r = subprocess.run(         [@@ -95,6 +134,16 @@ def ps(script, reason, timeout=120):         raise Unreadable("adom-bridge did not return JSON: %s%s"                          % (r.stdout[:200], (" / " + r.stderr[:200]) if r.stderr else ""))     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.+        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)         raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])     out = payload.get("stdout")     if out is None:@@ -102,19 +151,80 @@ def ps(script, reason, timeout=120):     return out.strip()  +# ── LOCAL MODE ──────────────────────────────────────────────────────────────+#+# `--local http://127.0.0.1:PORT/command` points every call at a bridge running+# HERE instead of tunnelling PowerShell to the desktop. It exists because this+# gate shipped four bugs of its own that only a live run could find — it asked+# for a pathless document and then failed the bridge for not giving it a path; it+# saved a twin to a path with no extension and reported the format refusal as a+# consent-gate bug; it tried to close a document that had never been created. All+# four are reproducible against the simulator in seconds. A gate that can only be+# tested on the machine it is meant to protect gets debugged in production.+LOCAL = None+++def local_verb(name, args=None, timeout=180):+    import urllib.request+    body = json.dumps({"command": name, "args": args or {}}).encode()+    req = urllib.request.Request(LOCAL, data=body,+                                 headers={"Content-Type": "application/json"})+    with urllib.request.urlopen(req, timeout=timeout) as r:+        return json.loads(r.read().decode())++ def verb(name, args=None, timeout=180):-    body = json.dumps({"command": name, "args": args or {}})-    script = (-        "$b=@'\n" + body + "\n'@; $p=\"$env:TEMP\\swx_draingate.json\"; "-        "[IO.File]::WriteAllText($p,$b); "-        f"curl.exe -s --max-time {timeout} -H \"Content-Type: application/json\" "-        f"--data-binary \"@$p\" {BRIDGE}"-    )-    out = ps(script, f"SOLIDWORKS drain gate: calling {name}", timeout)+    """Call a bridge verb THROUGH the desktop bridge's verb routing (adom-bridge+    routes swx_* to the bridge instance it hosts). Raises `Unreadable` rather+    than inventing an answer.++    MEASURED 2026-09-30: adom-bridge 2.1.163 hosts the released bridge itself+    (bridges-cache\\solidworks, its own port) and terminates a side-loaded copy+    on 24695 within 30 s, so the loopback route this helper used until 0.8.9 is+    dead. Set SWX_BRIDGE_URL to a loopback /command URL to force the old route.+    """+    if LOCAL:+        return local_verb(name, args, timeout)+    import os+    routed = not os.environ.get("SWX_BRIDGE_URL")+    if not routed:+        body = json.dumps({"command": name, "args": args or {}})+        script = (+            "$b=@'\n" + body + "\n'@; $p=\"$env:TEMP\\swx_gate.json\"; "+            "[IO.File]::WriteAllText($p,$b); "+            f"curl.exe -s --max-time {timeout} -H \"Content-Type: application/json\" "+            f"--data-binary \"@$p\" {os.environ['SWX_BRIDGE_URL']}"+        )+        out = ps(script, f"SOLIDWORKS gate: calling {name}", timeout)+    else:+        # A VERB THAT DOES NOT ANSWER IS 'could not ask', not a crash. Without+        # this, subprocess.TimeoutExpired escaped as a traceback — a gate dying+        # on a slow verb before it could print a verdict or clean up.+        try:+            r = subprocess.run(+                ["adom-bridge", "--target", "adom_drew2", "--ai-thread", THREAD, name,+                 json.dumps(args or {})],+                capture_output=True, text=True, timeout=timeout + 60,+            )+        except subprocess.TimeoutExpired:+            raise Unreadable("%s did not answer through adom-bridge within %ds" % (name, timeout + 60))+        out = r.stdout+        i = out.find("{")+        if i < 0:+            raise Unreadable("%s did not answer with JSON: %r%s" % (name, out[:200], (" / " + r.stderr[:200]) if r.stderr else ""))+        out = out[i:]     try:-        return json.loads(out)+        d = json.loads(out)     except Exception:         raise Unreadable("%s did not answer with JSON: %r" % (name, out[:300]))+    # adom-bridge wraps a ROUTED reply as {"status":..,"result":{...}} (and merges+    # top-level fields and adds an `output` string); the bridge's own reply is the+    # `result`. Only the routed reply is unwrapped: on the loopback route the JSON+    # IS the bridge's reply, and a verb field that happens to be called `result`+    # is not a wrapper.+    if routed and isinstance(d, dict) and isinstance(d.get("result"), dict):+        return d["result"]+    return d   def check(ok, claim, detail=""):@@ -144,6 +254,314 @@ def titles(rows):     return [str(d.get("title") or "") for d in rows]  +# ── WHAT THIS RUN CREATED ────────────────────────────────────────────────────+#+# Written down BEFORE each creation, so cleanup knows what is the gate's own on+# EVERY exit path — including the ones that used to skip it.+#+# Two live defects made this necessary. Cleanup ran only on the success path, so+# a Refused (exit 2) or a crash left this gate's documents OPEN IN DREW'S SESSION+# and its files on disk — and on that machine every run currently refuses behind+# the License Manager dialog, so the cleanup step had effectively never run. And+# the leftover check called the gate's own files foreign, because the only record+# of them was a variable assigned in a branch that the measured behaviour does+# not take.+GATE_PARENT = "swx-drain-gate"+MANIFEST = {"documents": [], "files": [], "dirs": []}+MANIFEST_PATH = None+_CLEANED = False+++def _cleanup_at_exit():+    """Returns the list of refusals, so the caller can raise the exit code."""+    """A Ctrl-C or an unhandled exception skips `finally` in some shells; this+    does not. Idempotent, so the normal path's cleanup is not repeated."""+    global _CLEANED+    if _CLEANED or MANIFEST_PATH is None:+        return []+    _CLEANED = True+    try:+        root = os.path.dirname(MANIFEST_PATH)+        return cleanup(_ARGS, root) or []+    except Exception as e:+        print("  cleanup at exit failed: %s" % str(e)[:200])+    return []+++atexit.register(_cleanup_at_exit)+++def _solidworks_session():+    """The running SOLIDWORKS process id, or None when it cannot be read.++    `None` is UNKNOWN and is treated as "not proven to be the same session" —+    the opt-in is withheld rather than granted on a guess.+    """+    try:+        st = verb("swx_status", {})+    except Exception:+        return None+    com = st.get("com") or {}+    for key in ("processId", "pid", "solidworksPid"):+        v = com.get(key) or st.get(key)+        if isinstance(v, int):+            return v+    return None+++def remember(kind, item):+    """Record a creation BEFORE making it, and persist immediately."""+    if item and item not in MANIFEST[kind]:+        MANIFEST[kind].append(item)+    if MANIFEST_PATH:+        try:+            with open(MANIFEST_PATH, "w") as fh:+                json.dump(MANIFEST, fh, indent=2)+        except OSError:+            pass+    return item+++def gate_parent(scratch_root):+    """The fixed directory this gate owns. Every run lives in a subdirectory of+    it, and NOTHING outside it is ever closed or deleted (see `owned`)."""+    return os.path.dirname(scratch_root.rstrip("/\\")) or scratch_root+++def owned(parent, path):+    """IS THIS PATH INSIDE THE DIRECTORY THIS GATE OWNS?++    Cleanup closes documents with `allowUserWrites` + `confirm`, which is a+    user-write that closes a document the bridge did not create. That is correct+    for the twin the gate itself opened and WRONG for anything else — and once a+    run can adopt another run's manifest, a `run-*.json` that a person or another+    tool had left in %TEMP% would feed arbitrary paths straight into it. So every+    path is checked for containment first, and anything outside is refused and+    reported rather than acted on.+    """+    if not path or not parent:+        return False+    try:+        a = os.path.realpath(str(path))+        b = os.path.realpath(str(parent))+        return os.path.commonpath([a, b]) == b and a != b+    except (ValueError, OSError):+        # Different drives, or an unreadable path: NOT ours. Refusing is the safe+        # direction for a check that guards a write.+        return False+++def prior_manifests(scratch_root):+    """Manifests left by EARLIER RUNS, found in the parent this gate owns.++    A run that died hard — SIGKILL, a crash, a reboot — cannot run its own+    cleanup, and `atexit` does not fire. The next run collects it.+    """+    parent = gate_parent(scratch_root)+    out = []+    try:+        for entry in sorted(os.listdir(parent)):+            run_dir = os.path.join(parent, entry)+            if not os.path.isdir(run_dir):+                continue+            for name in sorted(os.listdir(run_dir)):+                if name.startswith("run-") and name.endswith(".json"):+                    path = os.path.join(run_dir, name)+                    try:+                        with open(path) as fh:+                            out.append((path, json.load(fh)))+                    except (OSError, ValueError):+                        pass+    except OSError:+        pass+    return out+++def cleanup(a, scratch_root):+    """CLOSE AND REMOVE EXACTLY WHAT THIS RUN CREATED — on every exit path."""+    if a.keep:+        print("\nSTEP 6 — CLEANUP SKIPPED (--keep): this run's documents and files are left "+              "in place. Its manifest is at %s" % MANIFEST_PATH)+        return []+    print("\nSTEP 6 — CLEANUP (only what this gate created, from its own manifest)")+    refused = []+    current_session = _solidworks_session()+    parent = gate_parent(scratch_root)+    runs = [(MANIFEST_PATH, MANIFEST)] + [+        (p, m) for (p, m) in prior_manifests(scratch_root) if p != MANIFEST_PATH+    ]+    # THE TWIN IS THE ONLY DOCUMENT THAT NEEDS THE OPT-IN. Everything else this+    # gate made, the bridge made, and the default posture closes those.+    twin_paths = set()+    for _p, man in runs:+        for f in man.get("files", []):+            if "twin-" in str(f):+                twin_paths.add(str(f))+    for path, man in runs:+        for doc in man.get("documents", []):+            if not str(doc or "").strip():+                continue+            # CONTAINMENT GATES THE OPT-IN, not the close.+            #+            # Two different documents are in here. The TWIN is a file this gate+            # copied and opened, so the bridge sees a user document and closing it+            # needs allowUserWrites + confirm — a confirmed user-write, which must+            # never be pointed at an arbitrary path a manifest happened to name.+            # That one is checked for containment: it lives under the directory+            # this gate owns, and anything outside is refused and named.+            #+            # The others the BRIDGE created, and they live in the BRIDGE's scratch+            # area, not the gate's. Those are closed in the DEFAULT POSTURE — and+            # the default posture is itself the safety proof: the bridge refuses+            # to close a document it did not create. The gate does not assert that+            # ownership; the bridge decides it. Requiring containment here instead+            # left the gate's own documents open, which is the harm this whole+            # cleanup exists to prevent.+            inside = owned(parent, doc)+            if doc in twin_paths and not inside:+                refused.append(+                    "a manifest named a TWIN document OUTSIDE this gate's own directory and it "+                    "was NOT closed: %r is not under %r. Closing a twin is a confirmed "+                    "user-write, so it only ever touches what this gate could have written."+                    % (doc, parent))+                continue+            try:+                # THE OPT-IN, NAMED, FOR THE GATE'S OWN DOCUMENTS ONLY.+                #+                # Most of these the bridge created and can close in the default+                # posture. The TWIN cannot: the gate copied a file and opened it,+                # so from the bridge's point of view it is a user document, and+                # closing one needs allowUserWrites + confirm + a reason. Without+                # it the twin stayed open in the session — which is the very harm+                # this cleanup exists to prevent, one layer down.+                #+                # The scope is the manifest: only documents THIS RUN created.+                # THE OPT-IN IS SCOPED TO THE TWIN. Most of these the BRIDGE+                # created and closes in the default posture; only the twin — a+                # file this gate copied and opened, which the bridge therefore+                # sees as a user document — needs allowUserWrites + confirm.+                # Asking for it on every document would mean the widest possible+                # permission on every path a manifest happened to name.+                args = {"path": doc,+                        "reason": "drain gate cleanup: closing a document this gate created "+                                  "during a run, inside the directory it owns"}+                # THE SESSION MUST MATCH. A twin recorded against another+                # SOLIDWORKS is not this session's document, whatever its path+                # says, and the opt-in is the one authority this gate holds that+                # the bridge does not check for it.+                same_session = (man.get("sessionPid") is not None+                                and man.get("sessionPid") == current_session)+                if doc in twin_paths and inside and same_session:+                    args.update({+                        "allowUserWrites": True,+                        "confirm": True,+                        "reason": "drain gate cleanup: closing the same-title TWIN this gate "+                                  "copied and opened — the bridge did not create it, so closing "+                                  "it is a user write, and it is inside the gate's own directory",+                    })+                elif doc in twin_paths:+                    refused.append(+                        "a TWIN from another SOLIDWORKS session (manifest sessionPid=%r, running "+                        "now=%r) was NOT force-closed: %r. Its file is still removed — containment "+                        "proves this gate wrote it — but the write opt-in is withheld, because a "+                        "path is not proof of which session has the document open."+                        % (man.get("sessionPid"), current_session, doc))+                    continue+                c = verb("swx_close_doc", args)+            except Exception as e:          # a probe that cannot be read is not a reason to stop+                NOTES.append("could not close %s: %s" % (doc, str(e)[:120]))+                continue+            code = c.get("errorCode")+            if c.get("success") is not True and code not in ("document_not_open",+                                                             "no_target_document"):+                NOTES.append("could not close %s (%s) — it is still open on the user's screen"+                             % (doc, code))+        for f in man.get("files", []):+            if not owned(parent, f):+                refused.append(+                    "a manifest named a file OUTSIDE this gate's own directory and it was NOT "+                    "removed: %r is not under %r." % (f, parent))+                continue+            _remove_file(f)+        for d in sorted(man.get("dirs", []), key=len, reverse=True):+            if not owned(parent, d):+                refused.append(+                    "a manifest named a directory OUTSIDE this gate's own directory and it was "+                    "NOT removed: %r is not under %r." % (d, parent))+                continue+            _remove_dir_if_empty(d)+        if path and path != MANIFEST_PATH and owned(parent, path):+            _remove_file(path)+            _remove_dir_if_empty(os.path.dirname(path))+    _remove_file(MANIFEST_PATH)+    _remove_dir_if_empty(scratch_root)+    left = _listing(scratch_root)+    if left:+        NOTES.append(+            f"{scratch_root} still holds files this gate has no record of creating, so the "+            f"directory was left alone: {json.dumps(left)[:300]}")+    print("  cleanup: %d item(s) left in the scratch dir" % len(left))+    # REFUSALS ARE PRINTED HERE, not folded into a verdict that has already been+    # computed: cleanup runs from a `finally`, AFTER the exit code is decided, so+    # a FAILURES entry added here would never be seen. The caller raises the exit+    # code instead.+    for r in refused:+        print("  CLEANUP REFUSED: %s" % r)+    return refused+++def _remove_file(path):+    if not path:+        return+    if LOCAL:+        try:+            os.remove(path)+        except OSError:+            pass+        return+    try:+        ps("$f='%s'; if (Test-Path -LiteralPath $f) { Remove-Item -Force -LiteralPath $f } ; "+           "Write-Output ok" % str(path).replace("'", "''"),+           "SOLIDWORKS drain gate: removing one file it wrote", 60)+    except Unreadable:+        pass+++def _remove_dir_if_empty(path):+    if not path:+        return+    if LOCAL:+        try:+            os.rmdir(path)+        except OSError:+            pass+        return+    try:+        ps("$d='%s'; if (Test-Path -LiteralPath $d) { "+           "if (@(Get-ChildItem -Force -LiteralPath $d).Count -eq 0) { Remove-Item -Force "+           "-LiteralPath $d } } ; Write-Output ok" % str(path).replace("'", "''"),+           "SOLIDWORKS drain gate: removing its scratch directory if it is empty", 60)+    except Unreadable:+        pass+++def _listing(path):+    if LOCAL:+        try:+            return sorted(os.listdir(path))+        except OSError:+            return []+    try:+        out = ps("$d='%s'; if (Test-Path -LiteralPath $d) { "+                 "@(Get-ChildItem -Force -LiteralPath $d | ForEach-Object { $_.Name }) } "+                 "| ConvertTo-Json -Compress" % str(path).replace("'", "''"),+                 "SOLIDWORKS drain gate: listing what is left in its scratch directory", 60)+        v = json.loads(out) if out.strip() else []+        return v if isinstance(v, list) else [v]+    except (Unreadable, ValueError):+        return []++ def make_fixture(_unused=None):     """A minimal STEP file, written by us, in a directory we made."""     step = (@@ -152,8 +570,32 @@ def make_fixture(_unused=None):         "FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n"         % FIXTURE_STEM     )+    if LOCAL:+        import tempfile+        # ONE FIXED PARENT, PER-RUN SUBDIRECTORIES UNDER IT.+        #+        # 0.8.5 made the whole scratch root unique per run, which fixed "earlier+        # runs' files look foreign" by making earlier runs INVISIBLE: the manifest+        # scan looked inside the fresh directory, found only its own, and adopted+        # nothing. So a SIGKILL, a hard crash or a reboot — exactly what `atexit`+        # cannot cover — left a whole directory behind on every occurrence, with+        # no run that would ever collect them.+        #+        # A fixed PARENT that the gate owns, with a unique subdirectory per run,+        # gives both: no run can mistake another's files for a stranger's, and any+        # run can find and clean up what a dead one left.+        parent = os.path.join(tempfile.gettempdir(), GATE_PARENT)+        os.makedirs(parent, exist_ok=True)+        d = os.path.join(parent, "run-%d-%d" % (os.getpid(), int(time.time())))+        os.makedirs(d, exist_ok=False)+        with open(os.path.join(d, FIXTURE_STEM + ".step"), "w") as fh:+            fh.write(step)+        return d     script = (-        f"$d=Join-Path $env:TEMP 'swx-drain-gate'; New-Item -ItemType Directory -Force -Path $d | Out-Null; "+        f"$p=Join-Path $env:TEMP '{GATE_PARENT}'; "+        "New-Item -ItemType Directory -Force -Path $p | Out-Null; "+        f"$d=Join-Path $p 'run-{os.getpid()}-{int(time.time())}'; "+        "New-Item -ItemType Directory -Path $d -ErrorAction Stop | Out-Null; "         f"$f=Join-Path $d '{FIXTURE_STEM}.step'; "         "[IO.File]::WriteAllText($f, @'\n" + step + "\n'@); Write-Output $d"     )@@ -165,10 +607,61 @@ def make_fixture(_unused=None):   def main():+    """Run the gate, and CLEAN UP ON EVERY EXIT PATH.++    Cleanup used to live at the end of the happy path, so a Refused (exit 2), an+    Unreadable (exit 3), a crash or a Ctrl-C skipped it — leaving this gate's own+    documents OPEN in the user's session. On Drew's machine every run currently+    refuses behind the License Manager dialog, so it had effectively never run.+    """+    scratch_root = None+    a = None+    try:+        code, scratch_root = _run()+        return code+    finally:+        # THE SAME IDEMPOTENT CLEANUP the atexit hook calls, so the two cannot+        # both run — which they did, printing STEP 6 twice.+        if scratch_root is not None:+            refused = _cleanup_at_exit()+            if refused:+                print("\nGATE EXIT RAISED TO 1 — cleanup refused %d path(s) that a manifest "+                      "named outside this gate's own directory. Nothing outside it was closed "+                      "or deleted; somebody should look at why a manifest said that."+                      % len(refused))+                # A RETURN IN `finally` IS WHAT ACTUALLY CHANGES THE EXIT CODE.+                # Assigning to `code` here does nothing: the `return` in the try+                # block has already fixed the value, so the refusal printed and+                # the gate still exited 0.+                return 1+++class _Args:+    baseline = False+    keep = False+    fixture = "step"+    local = None+++_ARGS = _Args()+++def _run():     ap = argparse.ArgumentParser(description="prove the drain's close key on the real machine")     ap.add_argument("--baseline", action="store_true", help="step 1 only; creates nothing")     ap.add_argument("--keep", action="store_true", help="leave this gate's documents open")+    ap.add_argument("--local", metavar="URL",+                    help="run against a bridge on THIS machine (e.g. a mock on "+                         "http://127.0.0.1:PORT/command) instead of the desktop — the only way to "+                         "test the gate itself without touching the machine it protects")+    ap.add_argument("--fixture", choices=("step", "new"), default="step",+                    help="what the gate creates to drain: 'step' imports a STEP twice (needs 3D "+                         "Interconnect OFF), 'new' makes two documents with swx_new_document "+                         "(works regardless of that setting)")     a = ap.parse_args()+    global LOCAL, _ARGS, MANIFEST_PATH+    LOCAL = a.local+    _ARGS = a      print("=" * 72)     print("STEP 1 — BASELINE")@@ -191,23 +684,68 @@ def main():         print("    %-40s %s" % (str(d.get("title"))[:40], d.get("path")))     if a.baseline:         print("\nBASELINE ONLY — nothing was created. Re-run without --baseline for the experiment.")-        return 1 if FAILURES else 0+        return (1 if FAILURES else 0), None      scratch = make_fixture(None)+    # THE MANIFEST, opened before the first creation. `run-<pid>-<stamp>.json`+    # so concurrent or earlier runs each have their own and cleanup can adopt+    # the ones they left behind.+    MANIFEST_PATH = (scratch.rstrip("/\\") + (os.sep if LOCAL else "\\")+                     + "run-%d-%d.json" % (os.getpid(), int(time.time())))+    remember("files", scratch.rstrip("/\\") + (os.sep if LOCAL else "\\")+             + FIXTURE_STEM + ".step")+    # WHICH SOLIDWORKS THIS RUN'S DOCUMENTS BELONG TO.+    #+    # The twin close is the ONE place this tool overrides the bridge's ownership+    # answer — allowUserWrites + confirm on a document the bridge did not create.+    # Path containment says the file is one this gate could have written; it says+    # nothing about WHICH SOLIDWORKS has it open. A twin path from a dead run+    # (pid + epoch second) could name a document in a session that started+    # afterwards, and the opt-in would force it closed. So the session is recorded+    # and the opt-in is withheld when it differs — the FILE is still removed,+    # because that part is proven by containment.+    MANIFEST["sessionPid"] = _solidworks_session()+    remember("files", None)      # persist the session id immediately     fixture = scratch.rstrip("\\") + "\\" + FIXTURE_STEM + ".step"-    print("\nSTEP 2 — TWO IMPORT TEMPORARIES from %s" % fixture)+    if a.fixture == "new":+        print("\nSTEP 2 — TWO BRIDGE-CREATED DOCUMENTS (swx_new_document)")+    else:+        print("\nSTEP 2 — TWO IMPORT TEMPORARIES from %s" % fixture)     imported = []     for i in (1, 2):-        r = verb("swx_import", {"filePath": fixture,-                                "reason": "drain gate: creating a bridge-owned import temporary"})+        if a.fixture == "new":+            # THE SAME OWNERSHIP PATH, A DIFFERENT MAKER. new_document goes+            # through identity-at-birth and register_created exactly as import+            # does, so the registry this gate is really testing is the same one.+            # NO `keepUnsaved`. The gate asserts below that every document it+            # made can be addressed BY PATH, and `keepUnsaved:true` asks for the+            # opposite — a pathless document. The 2026-09-17 run failed itself on+            # exactly that: it requested no file and then reported the bridge for+            # not giving it one. A plain new_document gets identity-at-birth and+            # a scratch file, which is what this gate is here to exercise.+            r = verb("swx_new_document", {"type": "part",+                                          "reason": "drain gate: creating a bridge-owned document"})+        else:+            r = verb("swx_import", {"filePath": fixture,+                                    "reason": "drain gate: creating a bridge-owned import temporary"})         if r.get("success") is not True:-            raise Refused("swx_import failed, so this gate created nothing to drain: %s"-                          % json.dumps(r)[:400])-        imp = r.get("imported") or {}+            if a.fixture != "new" and r.get("errorCode") == "interconnect_blocks_import":+                raise Refused(+                    "swx_import is blocked by the user's 3D Interconnect setting, which this "+                    "bridge does not change. Re-run with --fixture new, which proves the same "+                    "ownership and collision properties using swx_new_document: %s"+                    % str(r.get("error"))[:300])+            raise Refused("%s failed, so this gate created nothing to drain: %s"+                          % ("swx_new_document" if a.fixture == "new" else "swx_import",+                             json.dumps(r)[:400]))+        imp = r.get("imported") or r.get("created") or r.get("document") or {}         ident = r.get("documentIdentity") or {}+        # RECORDED THE MOMENT IT EXISTS, before anything can fail.+        remember("documents", str(imp.get("path") or "") or str(imp.get("title") or ""))         imported.append(imp)-        print("  import %d -> title=%r path=%r identity=%r"-              % (i, imp.get("title"), imp.get("path"), ident.get("identity")))+        print("  %s %d -> title=%r path=%r identity=%r"+              % ("document" if a.fixture == "new" else "import",+                 i, imp.get("title"), imp.get("path"), ident.get("identity")))         # ADDRESS BY IDENTITY, NOT BY TITLE. Live on 2026-09-11 both imports came         # back with path:"" and the SAME title, and every verb that had to name         # one of them refused — correctly. 0.6.0 gives each one a file at birth;@@ -215,87 +753,233 @@ def main():         # address documents it cannot name.         if not str(imp.get("path") or "").strip():             FAILURES.append(-                "import %d came back with NO PATH (identity=%r). Two documents from one fixture "+                "document %d came back with NO PATH (identity=%r). Two documents from one fixture "                 "then share a title and neither can be addressed by any verb — which is the state "                 "0.6.0's identity-at-birth exists to prevent. Everything below this line is "                 "unreliable." % (i, ident.get("identity")))     temp_paths = [str(d.get("path") or "") for d in imported]     temp_title = str(imported[0].get("title") or "") -    print("\nSTEP 3 — A SAVED TWIN sharing that title")-    saved_path = scratch.rstrip("\\") + "\\" + temp_title-    # DEFAULT POSTURE FIRST, and the result is a FINDING of this gate.-    #-    # The document is one the BRIDGE created and the destination does not exist,-    # inside a directory the gate made — the choke-point rule is supposed to allow-    # exactly that with no `allowUserWrites`, because nothing of the user's can be-    # lost. If it declines, the live proof is unreachable on any bridge that is not-    # running wide open, and THAT IS A BRIDGE BUG worth naming rather than working-    # around silently. (It does not reproduce in the mock: see-    # `the_default_posture_lets_the_bridge_save_its_own_document_to_a_new_path`.)-    save_args = {-        # BY PATH. `name` is kept only as a fallback for a bridge older than-        # 0.6.0, where the import may have no path to name it by.-        "name": temp_title, "path": temp_paths[1], "out": saved_path, "copy": True,-        "reason": "drain gate: a saved document sharing a temporary's title, which is the shape "-                  "that made closing by title a data-loss path",-    }-    saved = verb("swx_save_as", dict(save_args))-    default_allowed = saved.get("success") is True-    print("  defaultGateAllowedScratchSave: %s%s"-          % (default_allowed, "" if default_allowed else-             "   [%s: %s]" % (saved.get("errorCode"), str(saved.get("error"))[:160])))-    if not default_allowed:-        code = saved.get("errorCode") or "?"-        # TWO VERY DIFFERENT REFUSALS, and the 2026-09-11 run reported the wrong-        # one. `ambiguous_target` is not the write gate saying no — it is the-        # bridge saying it cannot tell WHICH document was meant, which is a-        # property of the session (two open documents sharing a title and having-        # no path), not of the consent rules. Calling it a write-gate bug sent-        # the reader looking in the wrong module.-        if code in ("ambiguous_target", "document_not_open", "document_list_unavailable"):-            NOTES.append(-                "the twin save was refused with `%s` — this is an IDENTITY problem, not the write "-                "gate: the document could not be named, because a pathless document's only name is "-                "its title and another open document shares it. From 0.6.0 every bridge-created "-                "document is given a file at birth, so this should not recur; if it does, the "-                "identity step is what failed, not the consent rules." % code)+    # EVERY FILE THIS GATE WRITES, relative to the scratch dir, so cleanup removes+    # exactly those and nothing else. `twin_name` used to be defined in one branch+    # and read in both — which crashed cleanup on the other, again.+    twin_name = None+    if a.fixture == "new":+        # ── UNIQUENESS BY DESIGN, stated rather than worked around.+        #+        # Live, 2026-09-17: swx_save_as on a bridge-created document RE-PATHS+        # THAT DOCUMENT rather than producing a second one, so the twin step+        # produced no collision at all — "3 document(s) open, 1 of them titled+        # Part7-…". That is not a bug to route around: under identity-at-birth+        # two bridge-created documents CANNOT share a title, because each is+        # given its own file the moment it exists. So the `new` fixture asserts+        # the property instead of faking its opposite.+        print("\nSTEP 3 — UNIQUENESS BY DESIGN (no twin: two bridge-created documents cannot collide)")+        check(len(set(t for t in titles(docs()) if t in [str(d.get("title") or "") for d in imported])) ==+              len([d for d in imported if str(d.get("title") or "").strip()]),+              "the two bridge-created documents have DISTINCT titles",+              json.dumps([d.get("title") for d in imported]))+        check(len(set(temp_paths)) == len([p for p in temp_paths if p.strip()]),+              "...and DISTINCT paths, each its own file at birth",+              json.dumps(temp_paths))++        # ── AND A MEASUREMENT, because whether SOLIDWORKS permits a collision at+        #    all for SAVED documents is not something this gate should assume.+        #    Copy one document's file to a second directory under the SAME+        #    basename and open it: if SOLIDWORKS allows it, the collision case is+        #    real for saved documents; if it refuses (swFileWithSameTitleAlreadyOpen+        #    = 65536), then only PATHLESS documents can ever collide — which is+        #    worth knowing and worth writing down.+        print("\nSTEP 3b — MEASUREMENT: can two SAVED documents share a title?")+        # A FRESH SUBDIRECTORY, never reused and never overwritten: -Force and+        # exist_ok=True both accept somebody else's directory, and Copy-Item+        # -Force overwrites somebody else's file.+        second_dir = (scratch.rstrip("/\\") + (os.sep if LOCAL else "\\")+                      + "twin-%d" % int(time.time() * 1000))+        src = temp_paths[0]+        base = src.replace("\\", "/").rsplit("/", 1)[-1]+        dup = second_dir + (os.sep if LOCAL else "\\") + base+        made = False+        if LOCAL:+            try:+                os.makedirs(second_dir, exist_ok=False)+                remember("dirs", second_dir)+                remember("files", dup)+                import shutil+                if os.path.exists(dup):+                    raise OSError("refusing to overwrite an existing file at the twin path")+                shutil.copyfile(src, dup)+                made = True+            except OSError as e:+                NOTES.append("could not stage the same-title copy locally: %s" % e)         else:-            FAILURES.append(-                "BRIDGE GATE BUG (write gate): saving a BRIDGE-CREATED document to a NON-EXISTING "-                "path inside a bridge-created scratch dir was refused in the default posture (%s). "-                "Nothing of the user's could have been lost by it, and the rule is pinned in the "-                "suite — so this is a divergence between the live gate and the mock, not a policy "-                "decision." % code)-        # ...and then, having RECORDED it, retry with the opt-in so the rest of the-        # gate can still run. The finding above stands either way.-        retry = dict(save_args)-        retry.update({"allowUserWrites": True, "confirm": True})-        saved = verb("swx_save_as", retry)-        if saved.get("success") is True:-            NOTES.append("the twin was saved only WITH the write opt-in; the default-posture "-                         "refusal above is the finding")-    twin_open = False-    if saved.get("success") is not True:-        NOTES.append("swx_save_as declined even with the opt-in (%s) — the SAVED-twin half of the "-                     "premise is untested this run" % (saved.get("errorCode") or "?"))+            try:+                remember("dirs", second_dir)+                remember("files", dup)+                ps("$d='%s'; New-Item -ItemType Directory -Path $d -ErrorAction Stop | Out-Null; "+                   "Copy-Item -LiteralPath '%s' -Destination '%s'; Write-Output ok"+                   % (second_dir.replace("'", "''"), src.replace("'", "''"), dup.replace("'", "''")),+                   "SOLIDWORKS drain gate: copying its own document to a second path with the same "+                   "basename, to measure whether SOLIDWORKS allows two open documents to share a "+                   "title", 60)+                made = True+            except Unreadable as e:+                NOTES.append("could not stage the same-title copy: %s" % e)+        twin_open = False+        if made:+            opened = verb("swx_open", {"path": dup,+                                       "reason": "drain gate: measuring whether SOLIDWORKS permits "+                                                 "two open documents with the same title"})+            twin_open = opened.get("success") is True+            if twin_open:+                # AN OPENED DOCUMENT IS A CREATION TOO. Recording only the FILE+                # left the probe's document open in the user's session — the same+                # defect one layer down from the one this manifest exists to fix.+                remember("documents", dup)+            code = opened.get("errorCode")+            err = str(opened.get("error") or "")+            if twin_open:+                NOTES.append(+                    "MEASURED: SOLIDWORKS DID open a second document with the same basename (%s) "+                    "from a different directory — so a title collision between SAVED documents is "+                    "possible on this edition, and the drain's collision guard is exercised below."+                    % base)+                saved_path = dup+                twin_name = "twin" + (os.sep if LOCAL else "\\") + base+            elif "65536" in err or "same title" in err.lower() or "already open" in err.lower():+                NOTES.append(+                    "MEASURED: SOLIDWORKS REFUSED to open a second document with the same title. "+                    "Measured on date %s. Invocation: swx_open {path: %r} after copying a document's own "+                    "file to a second directory under the same basename. Result: %s / %s. "+                    "swFileWithSameTitleAlreadyOpen = 65536 — so for SAVED documents a title "+                    "collision is IMPOSSIBLE on this edition, and only PATHLESS documents can "+                    "ever collide. This is written down beside the guard it justifies, in "+                    "src/comhost/com.rs (the drain's title-collision check) — a note printed at "+                    "the end of a run is a note that is gone."+                    % (time.strftime("%Y-%m-%d"), dup, code, err[:200]))+            else:+                NOTES.append("the same-title probe was refused for another reason (%s: %s) — the "+                             "collision question is UNMEASURED this run" % (code, err[:200]))+        before_rows = docs()+        before_count = len(before_rows)+        same_titled = [d for d in before_rows if str(d.get("title") or "") == temp_title]+        print("  %d document(s) open, %d of them titled %r"+              % (before_count, len(same_titled), temp_title))     else:-        opened = verb("swx_open", {"path": saved_path,-                                   "reason": "drain gate: opening the saved twin so its title "-                                             "collides with the temporary's"})-        twin_open = opened.get("success") is True-        if not twin_open:-            NOTES.append("the saved twin did not open (%s); the collision half is untested"-                         % (opened.get("errorCode") or "?"))--    before_rows = docs()-    before_count = len(before_rows)-    same_titled = [d for d in before_rows if str(d.get("title") or "") == temp_title]-    print("  %d document(s) open, %d of them titled %r" % (before_count, len(same_titled), temp_title))--    print("\nSTEP 4 — ONE BATCH: swx_close_import_temps {batch:1}")-    r = verb("swx_close_import_temps", {"batch": 1, "reason": "drain gate: the live proof of the "-                                                              "close key, one document"})+        print("\nSTEP 3 — A SAVED TWIN sharing that title")+        # SaveAs3 DISPATCHES ITS WRITER ON THE EXTENSION, so a destination with none+        # is refused with `swFileSaveError_e 256` (format not supported) — which is+        # not a consent decision at all. A `new_document` title is bare ("Part2"),+        # where an import's carried `.SLDPRT` already, so the extension is added when+        # the title does not have one.+        twin_name = temp_title if "." in temp_title else temp_title + ".SLDPRT"+        # The separator belongs to the machine the BRIDGE runs on: a backslash on the+        # desktop, and the host's own in local mode — where a Windows path would make+        # one long filename in /tmp instead of a file in the scratch directory, and+        # cleanup would then honestly report nothing left while a file sat outside it.+        sep = "\\" if not LOCAL else os.sep+        saved_path = scratch.rstrip("/\\") + sep + twin_name+        # DEFAULT POSTURE FIRST, and the result is a FINDING of this gate.+        #+        # The document is one the BRIDGE created and the destination does not exist,+        # inside a directory the gate made — the choke-point rule is supposed to allow+        # exactly that with no `allowUserWrites`, because nothing of the user's can be+        # lost. If it declines, the live proof is unreachable on any bridge that is not+        # running wide open, and THAT IS A BRIDGE BUG worth naming rather than working+        # around silently. (It does not reproduce in the mock: see+        # `the_default_posture_lets_the_bridge_save_its_own_document_to_a_new_path`.)+        save_args = {+            # BY PATH. `name` is kept only as a fallback for a bridge older than+            # 0.6.0, where the import may have no path to name it by.+            "name": temp_title, "path": temp_paths[1], "out": saved_path, "copy": True,+            "reason": "drain gate: a saved document sharing a temporary's title, which is the shape "+                      "that made closing by title a data-loss path",+        }+        saved = verb("swx_save_as", dict(save_args))+        default_allowed = saved.get("success") is True+        print("  defaultGateAllowedScratchSave: %s%s"+              % (default_allowed, "" if default_allowed else+                 "   [%s: %s]" % (saved.get("errorCode"), str(saved.get("error"))[:160])))+        if not default_allowed:+            code = saved.get("errorCode") or "?"+            # TWO VERY DIFFERENT REFUSALS, and the 2026-09-11 run reported the wrong+            # one. `ambiguous_target` is not the write gate saying no — it is the+            # bridge saying it cannot tell WHICH document was meant, which is a+            # property of the session (two open documents sharing a title and having+            # no path), not of the consent rules. Calling it a write-gate bug sent+            # the reader looking in the wrong module.+            # A REFUSAL IS NOT A REFUSAL BY THE GATE. Classify by CODE, never by+            # "it failed": the 2026-09-17 run reported a FORMAT refusal+            # (`swFileSaveError_e 256`, a destination with no extension — the gate's+            # own bug) as "BRIDGE GATE BUG (write gate) ... refused in the default+            # posture", which is an accusation about the consent rules on the+            # strength of an error that has nothing to do with them. A gate that+            # cannot tell its own bug from the bridge's is worse than no gate.+            FORMAT_OR_IO = ("format_not_supported", "save_failed", "export_failed",+                            "invalid_path", "path_not_writable", "destination_unverifiable")+            IDENTITY = ("ambiguous_target", "document_not_open", "document_list_unavailable")+            if code in FORMAT_OR_IO or "format" in str(saved.get("error") or "").lower():+                FAILURES.append(+                    "THE GATE ASKED FOR SOMETHING SOLIDWORKS CANNOT WRITE (%s: %s). This is the "+                    "gate's bug, not the bridge's — SaveAs3 dispatches on the destination extension, "+                    "so the twin path must end in .SLDPRT. Fix the gate."+                    % (code, str(saved.get("error"))[:200]))+            elif code in IDENTITY:+                NOTES.append(+                    "the twin save was refused with `%s` — this is an IDENTITY problem, not the write "+                    "gate: the document could not be named, because a pathless document's only name is "+                    "its title and another open document shares it. From 0.6.0 every bridge-created "+                    "document is given a file at birth, so this should not recur; if it does, the "+                    "identity step is what failed, not the consent rules." % code)+            else:+                FAILURES.append(+                    "BRIDGE GATE BUG (write gate): saving a BRIDGE-CREATED document to a NON-EXISTING "+                    "path inside a bridge-created scratch dir was refused in the default posture (%s). "+                    "Nothing of the user's could have been lost by it, and the rule is pinned in the "+                    "suite — so this is a divergence between the live gate and the mock, not a policy "+                    "decision." % code)+            # ...and then, having RECORDED it, retry with the opt-in so the rest of the+            # gate can still run. The finding above stands either way.+            retry = dict(save_args)+            retry.update({"allowUserWrites": True, "confirm": True})+            saved = verb("swx_save_as", retry)+            if saved.get("success") is True:+                NOTES.append("the twin was saved only WITH the write opt-in; the default-posture "+                             "refusal above is the finding")+        twin_open = False+        if saved.get("success") is not True:+            NOTES.append("swx_save_as declined even with the opt-in (%s) — the SAVED-twin half of the "+                         "premise is untested this run" % (saved.get("errorCode") or "?"))+        else:+            opened = verb("swx_open", {"path": saved_path,+                                       "reason": "drain gate: opening the saved twin so its title "+                                                 "collides with the temporary's"})+            twin_open = opened.get("success") is True+            if not twin_open:+                NOTES.append("the saved twin did not open (%s); the collision half is untested"+                             % (opened.get("errorCode") or "?"))++        before_rows = docs()+        before_count = len(before_rows)+        same_titled = [d for d in before_rows if str(d.get("title") or "") == temp_title]+        print("  %d document(s) open, %d of them titled %r" % (before_count, len(same_titled), temp_title))++    # WHICH DRAIN, AND WHY. `swx_close_import_temps` identifies victims BY PATH+    # (a `IC~~` conversion temp), which is the ownership proof that survives a pid+    # change — so it CORRECTLY does not touch a bridge-created new document, which+    # has no such path. The ownership registry is what both fixtures share, so the+    # `new` fixture drains through the verb that reads it.+    if a.fixture == "new":+        print("\nSTEP 4 — THE REGISTRY DRAIN: swx_reset_workspace")+        print("  (swx_close_import_temps is IMPORT-TEMP-BY-PATH by definition and correctly does")+        print("   not drain a pathless bridge-created document; the registry is the ownership")+        print("   proof this fixture exercises)")+        r = verb("swx_reset_workspace", {"reason": "drain gate: closing the documents this bridge "+                                                   "created, by the ownership registry"})+    else:+        print("\nSTEP 4 — ONE BATCH: swx_close_import_temps {batch:1}")+        r = verb("swx_close_import_temps", {"batch": 1, "reason": "drain gate: the live proof of the "+                                                                  "close key, one document"})     print("  " + json.dumps({k: r.get(k) for k in                              ("success", "errorCode", "closed", "closedDocs", "done", "walkStop",                               "titleCollisions", "titleCollisionsCheckedOver", "closeIgnoredPath",@@ -310,6 +994,25 @@ def main():         check(False, "THE PREMISE FAILED: CloseDoc(<full path>) closed nothing on this build",               "the bridge refused rather than retrying by title, which is correct — but the drain "               "cannot work until the close key is settled another way")+    elif a.fixture == "new":+        # THE SAME TWO PROPERTIES, asked of the registry drain: OUR documents go,+        # and the twin sharing a title does NOT.+        after_rows = docs()+        after_paths = paths(after_rows)+        after_titles = titles(after_rows)+        check(r.get("success") is True, "the registry drain ran", json.dumps(r)[:300])+        closed = r.get("closed") or []+        check(len(closed) >= 2,+              "both bridge-created documents were closed", json.dumps(closed)[:300])+        if twin_open:+            check(saved_path in after_paths,+                  "the SAVED twin sharing the title is STILL OPEN", saved_path)+            check(temp_title in after_titles,+                  "...so that title is still present on a document this gate did not create",+                  temp_title)+        check(base_paths <= after_paths,+              "every document open before this gate ran is still open",+              json.dumps(sorted(base_paths - after_paths))[:300])     else:         after_rows = docs()         after_paths = paths(after_rows)@@ -335,37 +1038,7 @@ def main():               json.dumps(sorted(base_paths - after_paths))[:300])         _ = gone -    if not a.keep:-        print("\nSTEP 6 — CLEANUP (only what this gate created)")-        for p in set(temp_paths + ([saved_path] if twin_open else [])):-            c = verb("swx_close_doc", {"path": p, "reason": "drain gate: closing its own document"})-            if c.get("success") is not True and c.get("errorCode") != "document_not_open":-                NOTES.append("could not close %s (%s) — it is still open on the user's screen"-                             % (p, c.get("errorCode")))-        # BY NAME, never a recursive delete. This gate wrote exactly two files-        # and one directory; SOLIDWORKS may have put its own artefacts in there,-        # and `Remove-Item -Recurse -Force` on a directory whose contents we did-        # not write is the same class of act this whole bridge refuses everywhere-        # else. The directory is removed only if it is EMPTY afterwards.-        wrote = [FIXTURE_STEM + ".step", temp_title]-        listing = "@(" + ",".join("'%s'" % w.replace("'", "''") for w in wrote) + ")"-        rm = ps(-            "$d=Join-Path $env:TEMP 'swx-drain-gate'; $names=" + listing + "; "-            "$left=@(); foreach ($n in $names) { $f=Join-Path $d $n; "-            "if (Test-Path $f) { Remove-Item -Force $f -ErrorAction SilentlyContinue } } "-            "if (Test-Path $d) { $left=@(Get-ChildItem $d -Force | ForEach-Object { $_.Name }); "-            "if ($left.Count -eq 0) { Remove-Item -Force $d } } "-            "@{ left=$left } | ConvertTo-Json -Compress",-            "SOLIDWORKS drain gate: deleting ONLY the two files it wrote, and the directory only "-            "if it is then empty", 60)-        try:-            left = json.loads(rm).get("left")-        except Exception:-            left = "unreadable"-        if left:-            NOTES.append("%TEMP%\\swx-drain-gate still holds files this gate did not write, so "-                         "the directory was left alone: %s" % json.dumps(left)[:300])-+    _verdict_marker = None     print("\n" + "=" * 72)     for n in NOTES:         print("NOTE: " + n)@@ -373,16 +1046,20 @@ def main():         print("GATE FAILED — %d claim(s) did not hold:" % len(FAILURES))         for f in FAILURES:             print("  - " + f)-        return 1+        return 1, scratch     if not twin_open:         print("GATE PARTIAL — the CLOSE KEY is proven: CloseDoc(<full path>) closed exactly the "               "document it was given and nothing else moved. The COLLISION half is UNTESTED this "               "run (the saved twin was never open), so nothing here says what happens when two "               "open documents share a title. Re-run once swx_save_as succeeds.")-        return 0+        # EXIT 3 — UNPROVEN, never 0. A caller reading the status could not tell+        # this from a full pass, and one of the two properties this gate exists+        # to prove is untested. 3 is this suite's code for "a probe could not be+        # READ", which is exactly what an untested half is.+        return 3, scratch     print("GATE PASSED — CloseDoc(<full path>) closes exactly the document it was given, the "           "same-titled saved document survived, and nothing else moved.")-    return 0+    return 0, scratch   if __name__ == "__main__":
tools/launch-gate.py+62−15
@@ -52,7 +52,9 @@ import subprocess import sys import time -BRIDGE = "http://127.0.0.1:24695/command"+# THE ROUTE: verbs go through adom-bridge's verb routing to the bridge it hosts+# (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" CHAIN = ("CATSTART", "SWXDesktopLauncher") NEVER_TOUCH = ("3DEXPERIENCELauncher", "3DEXPERIENCELauncherSysTray", "3DEXPERIENCELauncherService")@@ -74,7 +76,7 @@ class Unreadable(Exception):     """  -def ps(script, reason, timeout=120):+def ps(script, reason, timeout=120, _retry=False):     """Run PowerShell on Drew's desktop and return its stdout.      NOTE ON `shell_execute`: this is ab's general escape hatch — it runs an@@ -103,6 +105,16 @@ def ps(script, reason, timeout=120):         raise Unreadable("adom-bridge did not return JSON: %s%s"                          % (r.stdout[:200], (" / " + r.stderr[:200]) if r.stderr else ""))     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.+        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)         raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])     out = payload.get("stdout")     if out is None:@@ -111,21 +123,55 @@ def ps(script, reason, timeout=120):   def verb(name, args=None, timeout=120):-    """Call a bridge verb. Raises `Unreadable` rather than inventing an answer."""-    body = json.dumps({"command": name, "args": args or {}})-    script = (-        "$b=@'\n" + body + "\n'@; $p=\"$env:TEMP\\swx_gate.json\"; "-        "[IO.File]::WriteAllText($p,$b); "-        f"curl.exe -s --max-time {timeout} -H \"Content-Type: application/json\" "-        f"--data-binary \"@$p\" {BRIDGE}"-    )-    out = ps(script, f"SOLIDWORKS launch gate: calling {name}", timeout)+    """Call a bridge verb THROUGH the desktop bridge's verb routing (adom-bridge+    routes swx_* to the bridge instance it hosts). Raises `Unreadable` rather+    than inventing an answer.++    MEASURED 2026-09-30: adom-bridge 2.1.163 hosts the released bridge itself+    (bridges-cache\\solidworks, its own port) and terminates a side-loaded copy+    on 24695 within 30 s, so the loopback route this helper used until 0.8.9 is+    dead. Set SWX_BRIDGE_URL to a loopback /command URL to force the old route.+    """+    import os+    routed = not os.environ.get("SWX_BRIDGE_URL")+    if not routed:+        body = json.dumps({"command": name, "args": args or {}})+        script = (+            "$b=@'\n" + body + "\n'@; $p=\"$env:TEMP\\swx_gate.json\"; "+            "[IO.File]::WriteAllText($p,$b); "+            f"curl.exe -s --max-time {timeout} -H \"Content-Type: application/json\" "+            f"--data-binary \"@$p\" {os.environ['SWX_BRIDGE_URL']}"+        )+        out = ps(script, f"SOLIDWORKS gate: calling {name}", timeout)+    else:+        # A VERB THAT DOES NOT ANSWER IS 'could not ask', not a crash. Without+        # this, subprocess.TimeoutExpired escaped as a traceback — a gate dying+        # on a slow verb before it could print a verdict or clean up.+        try:+            r = subprocess.run(+                ["adom-bridge", "--target", "adom_drew2", "--ai-thread", THREAD, name,+                 json.dumps(args or {})],+                capture_output=True, text=True, timeout=timeout + 60,+            )+        except subprocess.TimeoutExpired:+            raise Unreadable("%s did not answer through adom-bridge within %ds" % (name, timeout + 60))+        out = r.stdout+        i = out.find("{")+        if i < 0:+            raise Unreadable("%s did not answer with JSON: %r%s" % (name, out[:200], (" / " + r.stderr[:200]) if r.stderr else ""))+        out = out[i:]     try:-        return json.loads(out)+        d = json.loads(out)     except Exception:         raise Unreadable("%s did not answer with JSON: %r" % (name, out[:300]))--+    # adom-bridge wraps a ROUTED reply as {"status":..,"result":{...}} (and merges+    # top-level fields and adds an `output` string); the bridge's own reply is the+    # `result`. Only the routed reply is unwrapped: on the loopback route the JSON+    # IS the bridge's reply, and a verb field that happens to be called `result`+    # is not a wrapper.+    if routed and isinstance(d, dict) and isinstance(d.get("result"), dict):+        return d["result"]+    return d # ── the machine, measured ───────────────────────────────────────────────────  PROBE = r"""@@ -333,7 +379,8 @@ def main():           f"preferred={st.get('launch', {}).get('preferredRoute')}")     if st.get("solidworks", {}).get("running"):         print("\nSOLIDWORKS IS ALREADY RUNNING. This gate must start from a closed one —")-        print("ask Drew to close it, or run with --baseline to look without touching anything.")+        print("run swx_close_all_safely (it preserves every document to recovery, verified, "+              "before closing), or --baseline to look without touching anything.")         if not baseline_only:             return 2 
tools/quit-gate.py+163−18
@@ -72,7 +72,9 @@ import subprocess import sys import time -BRIDGE = "http://127.0.0.1:24695/command"+# THE ROUTE: verbs go through adom-bridge's verb routing to the bridge it hosts+# (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"  # The per-session servers a graceful exit should reap (QUIT-INVESTIGATION §0.2).@@ -109,7 +111,7 @@ class Refused(Exception):     """The human preconditions were not met. Nothing was attempted."""  -def ps(script, reason, timeout=120):+def ps(script, reason, timeout=120, _retry=False):     """Run PowerShell on Drew's desktop and return stdout.      Every command this file sends: `Get-CimInstance Win32_Process` (a census),@@ -136,6 +138,16 @@ def ps(script, reason, timeout=120):         raise Unreadable("adom-bridge did not return JSON: %s%s"                          % (r.stdout[:200], (" / " + r.stderr[:200]) if r.stderr else ""))     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.+        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)         raise Unreadable("shell_execute failed: %s" % json.dumps(payload)[:300])     out = payload.get("stdout")     if out is None:@@ -144,19 +156,55 @@ def ps(script, reason, timeout=120):   def verb(name, args=None, timeout=120):-    """Call a bridge verb. Raises rather than inventing an answer."""-    body = json.dumps({"command": name, "args": args or {}})-    script = (-        "$b=@'\n" + body + "\n'@; $p=\"$env:TEMP\\swx_quitgate.json\"; "-        "[IO.File]::WriteAllText($p,$b); "-        f"curl.exe -s --max-time {timeout} -H \"Content-Type: application/json\" "-        f"--data-binary \"@$p\" {BRIDGE}"-    )-    out = ps(script, f"SOLIDWORKS quit gate: calling {name}", timeout)+    """Call a bridge verb THROUGH the desktop bridge's verb routing (adom-bridge+    routes swx_* to the bridge instance it hosts). Raises `Unreadable` rather+    than inventing an answer.++    MEASURED 2026-09-30: adom-bridge 2.1.163 hosts the released bridge itself+    (bridges-cache\\solidworks, its own port) and terminates a side-loaded copy+    on 24695 within 30 s, so the loopback route this helper used until 0.8.9 is+    dead. Set SWX_BRIDGE_URL to a loopback /command URL to force the old route.+    """+    import os+    routed = not os.environ.get("SWX_BRIDGE_URL")+    if not routed:+        body = json.dumps({"command": name, "args": args or {}})+        script = (+            "$b=@'\n" + body + "\n'@; $p=\"$env:TEMP\\swx_gate.json\"; "+            "[IO.File]::WriteAllText($p,$b); "+            f"curl.exe -s --max-time {timeout} -H \"Content-Type: application/json\" "+            f"--data-binary \"@$p\" {os.environ['SWX_BRIDGE_URL']}"+        )+        out = ps(script, f"SOLIDWORKS gate: calling {name}", timeout)+    else:+        # A VERB THAT DOES NOT ANSWER IS 'could not ask', not a crash. Without+        # this, subprocess.TimeoutExpired escaped as a traceback — a gate dying+        # on a slow verb before it could print a verdict or clean up.+        try:+            r = subprocess.run(+                ["adom-bridge", "--target", "adom_drew2", "--ai-thread", THREAD, name,+                 json.dumps(args or {})],+                capture_output=True, text=True, timeout=timeout + 60,+            )+        except subprocess.TimeoutExpired:+            raise Unreadable("%s did not answer through adom-bridge within %ds" % (name, timeout + 60))+        out = r.stdout+        i = out.find("{")+        if i < 0:+            raise Unreadable("%s did not answer with JSON: %r%s" % (name, out[:200], (" / " + r.stderr[:200]) if r.stderr else ""))+        out = out[i:]     try:-        return json.loads(out)+        d = json.loads(out)     except Exception:         raise Unreadable("%s did not answer with JSON: %r" % (name, out[:300]))+    # adom-bridge wraps a ROUTED reply as {"status":..,"result":{...}} (and merges+    # top-level fields and adds an `output` string); the bridge's own reply is the+    # `result`. Only the routed reply is unwrapped: on the loopback route the JSON+    # IS the bridge's reply, and a verb field that happens to be called `result`+    # is not a wrapper.+    if routed and isinstance(d, dict) and isinstance(d.get("result"), dict):+        return d["result"]+    return d   # ── the machine, measured ───────────────────────────────────────────────────@@ -434,9 +482,95 @@ def prove_zero_documents(base):         raise Refused("the document count is UNKNOWN (documentCount=%r unsavedDocuments=%r) — "                       "refusing to quit on a guess." % (count, unsaved))     if count != 0 or unsaved != 0:-        raise Refused("%r document(s) open, %r unsaved. This gate ENDS the session and must never "-                      "run with the user's work in it. Ask Drew to close them himself." %-                      (count, unsaved))+        # THE BRIDGE CLOSES. Drew, 2026-09-11: "I despise every time you ask for+        # help closing. You MUST find/build a way to safely close the+        # docs/program." This gate used to end here by telling him to do it+        # himself, which is the sentence that directive exists to delete — and it+        # was still in the tool a week after the sweep that makes it unnecessary+        # shipped and ran live.+        #+        # swx_close_all_safely writes every open document to a recovery folder,+        # VERIFIES each one (container marker + per-kind size), and only then+        # closes it; anything it could not preserve is left open and named. So the+        # gate sweeps, and refuses only if the SWEEP refuses — saying which of its+        # reasons applied, because they are not interchangeable.+        print("  %r document(s) open, %r unsaved — closing them SAFELY first "+              "(swx_close_all_safely preserves every one to recovery, verified, before closing)"+              % (count, unsaved))+        swept = verb("swx_close_all_safely", {+            "reason": "quit gate: emptying the session safely so the quit ladder can be measured "+                      "on a session with zero documents open",+        })+        if swept.get("success") is not True or swept.get("done") is not True:+            code = swept.get("errorCode") or "?"+            why = {+                "stopped_by_dialog":+                    "a dialog is on screen and this bridge never answers one: %s"+                    % json.dumps(swept.get("stoppedByDialog"))[:300],+                "dialog_census_unknown":+                    "the window census could not be read, so whether a dialog is up is UNKNOWN — "+                    "the documents were preserved and NONE were closed",+                "insufficient_headroom":+                    "the recovery volume cannot hold the session: %s"+                    % json.dumps(swept.get("stoppedByHeadroom"))[:200],+                "identity_mismatch":+                    "the running SOLIDWORKS is not the session this bridge is attributed to",+            }.get(code, "the sweep stopped: %s" % str(swept.get("error"))[:300])+            remaining = swept.get("remaining") or []+            raise Refused(+                "the session could not be emptied safely, so the quit is not attempted. %s%s "+                "NOTHING was lost: every document the sweep could preserve is in %s, and anything "+                "it could not preserve is still open and named in `remaining`."+                % (why,+                   (" Documents it could not preserve: %s." % json.dumps(remaining)[:300])+                   if remaining else "",+                   swept.get("recoveryDir")))+        print("  swept: %d preserved, %d closed, recoveryDir=%s"+              % (len(swept.get("recovered") or []), swept.get("closed") or 0,+                 swept.get("recoveryDir")))+        # RE-MEASURE. Everything that was measured the first time — all four+        # conditions, not just the count.+        #+        # The preamble above disqualifies a session on FOUR grounds: a truncated+        # enumeration (UNKNOWN is not permission), an unknown count or unsaved+        # figure, a non-zero count, and a count that disagrees with the listing.+        # The first version of this re-measure re-read `unsavedDocuments` into a+        # variable it never tested, never re-read `documentsTruncated`, and never+        # re-fetched the listing — so the cross-check below compared a POST-sweep+        # count against a PRE-sweep list, and the sweep is the very thing that+        # invalidates that list.+        state = verb("swx_get_app_state", {})+        docs = verb("swx_docs", {})+        count = state.get("documentCount")+        unsaved = state.get("unsavedDocuments")+        truncated = state.get("documentsTruncated")+        listed = docs.get("docs") if isinstance(docs.get("docs"), list) else None+        if truncated or docs.get("truncated"):+            raise Refused(+                "after the safe close the document enumeration is TRUNCATED, so 'the session is "+                "empty' cannot be proven. UNKNOWN is not permission, and this gate ENDS the "+                "session.")+        if count is None or unsaved is None:+            raise Refused(+                "after the safe close the document count is UNKNOWN (documentCount=%r "+                "unsavedDocuments=%r) — refusing to quit on a guess." % (count, unsaved))+        if count != 0 or unsaved != 0:+            raise Refused(+                "the safe close reported done but %r document(s) are open and %r unsaved — "+                "refusing to quit on a session whose state this gate cannot confirm is empty. "+                "Nothing was lost: whatever the sweep preserved is in its recovery folder."+                % (count, unsaved))+        if listed is None:+            raise Refused(+                "after the safe close swx_docs returned no usable list, so the count cannot be "+                "cross-checked against the enumeration. That is UNKNOWN, not empty.")+        if len(listed) != 0:+            raise Refused(+                "after the safe close documentCount is 0 but the enumeration still lists %d "+                "document(s) — the two disagree, and a quit on a disagreement is a quit on a "+                "guess: %s" % (len(listed), json.dumps([d.get("title") for d in listed])[:200]))+        print("  re-measured after the sweep: count=0, unsaved=0, enumeration empty, "+              "not truncated")     # A DRAIN THAT MATCHED NOTHING IS NOT A DRAIN THAT FOUND NOTHING, and the     # operator's next step after a refusal here is the drain rather than the quit     # — so say whether the open documents LOOK like leaked import temporaries.@@ -457,7 +591,10 @@ def prove_zero_documents(base):                       "is evidence." % len(listed))     if base["dialogs"]:         raise Refused("a dialog is on screen (%s). This bridge never answers one, and a gate must "-                      "not either — ask Drew to answer it." %+                      "not either. A DIALOG is the one thing that is genuinely the user's to answer — "+                      "this bridge never answers one — so show them a screenshot (swx_state) and "+                      "let them decide. Closing documents is NOT theirs to do: the sweep above "+                      "does that." %                       [w.get("title") for w in base["dialogs"]])     if base["status"].get("solidworks", {}).get("busy") is True:         raise Refused("SOLIDWORKS reports BUSY — a rung would land on a modal operation.")@@ -471,8 +608,14 @@ def run_ladder(base, max_rung, allow_rm, reason):     print("\n" + "=" * 72)     print("STEP 2 — THE LADDER (%s)" % ("maxRung=" + max_rung if max_rung else "the bridge default"))     args = {+        # Drew's STANDING AUTHORIZATION (2026-09-11): `userAuthorized:true` with a+        # reason is the grant for ending the session (writes.rs, swx_stop). The+        # write-gate pair (allowUserWrites/confirm + SWX_ALLOW_USER_WRITES on the+        # bridge process) is NOT what authorizes a quit, and the bridge that ab+        # hosts never carries that env var - measured 2026-09-30: the gate was+        # refused `user_writes_disabled` while attribution was intact.+        "userAuthorized": True,         "confirm": True,-        "allowUserWrites": True,         "reason": reason,         # `force` waives the UNSAVED question only and there is nothing unsaved,         # so it is deliberately NOT passed: the gate should exercise the same@@ -544,7 +687,9 @@ def run_ladder(base, max_rung, allow_rm, reason):                str(t.get("detail") or "")[:120]))     if r.get("blockingDialog"):         NOTES.append("a dialog stopped the ladder: %s — it is STILL ON SCREEN and this gate did "-                     "not answer it. Ask Drew." % json.dumps(r["blockingDialog"]))+                     "not answer it — show the user a screenshot (swx_state) and let them "+                     "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",
tools/release.sh+12
@@ -17,6 +17,18 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT" VER="$(tr -d ' \n' < BRIDGE_VERSION)" PAGE="adom/solidworks-bridge"; SLUG="solidworks-bridge" EXE="target/x86_64-pc-windows-gnu/release/swx-bridge.exe"+# THE RELEASE MANIFEST DESCRIBES THE PUBLISHED RELEASE, NOT THE WORKING TREE.+# It carries the version, URL, sha256 and size of what is actually downloadable,+# and THIS SCRIPT rewrites it at publish time. So a version bump in Cargo.toml+# does NOT bump it: pointing it at a URL that does not exist yet would break+# every install until the release is cut. `bridge.json` is the one that must+# track the crate version (pinned by tests/adversary33.rs); this one must track+# the release: it names whatever was last published and committed, until the+# next publish rewrites it.+# A RELEASE IS A DEPLOY. ab hosts whatever version the PUBLISHED manifest names+# (measured 2026-09-30: adom-bridge 2.1.163 auto-installed 0.8.9 from it), so+# --publish puts this build on every machine that runs the bridge. There is no+# later step at which a bad build can still be stopped. MAN="adom-bridge-solidworks-manifest.json" ZIP="swx-bridge-v${VER}.zip" 
tools/swx-live.py+14−16
@@ -1,19 +1,17 @@ #!/usr/bin/env python3-"""swx.py <verb> [json-args] [timeoutSeconds] - call the dev bridge on adom_drew2 via curl.exe on the desktop."""-import json,subprocess,sys+"""swx-live.py <verb> [json-args] [timeoutSeconds] - call the SOLIDWORKS bridge on adom_drew2 through adom-bridge verb routing (2026-09-30: ab hosts the bridge; loopback 24695 is dead)."""+import sys, json, subprocess verb=sys.argv[1]; args=sys.argv[2] if len(sys.argv)>2 else '{}'; tmo=int(sys.argv[3]) if len(sys.argv)>3 else 120-body=json.dumps({"command":verb,"args":json.loads(args)})-# write body to a temp file on the desktop to dodge cmd quoting, then curl it-ps=("$b=@'\n"+body+"\n'@; $p=\"$env:TEMP\\swx_req.json\"; [IO.File]::WriteAllText($p,$b); "-    "curl.exe -s --max-time "+str(tmo)+" -H \"Content-Type: application/json\" --data-binary \"@$p\" http://127.0.0.1:24695/command")-import base64-enc=base64.b64encode(ps.encode('utf-16le')).decode()-r=subprocess.run(['adom-bridge','--target','adom_drew2','--ai-thread','SolidWorks Bridge','shell_execute',-    json.dumps({"command":"powershell -NoProfile -EncodedCommand "+enc,"timeoutSeconds":tmo+30,-                "reason":"Calling the SOLIDWORKS bridge verb "+verb+" on your PC to validate it against the running SOLIDWORKS"})],-    capture_output=True,text=True) try:-    d=json.loads(r.stdout); out=(d.get('stdout') or '').strip()-    try: print(json.dumps(json.loads(out),indent=1)[:4000])-    except Exception: print('RAW:',out[:1500],'| stderr:',(d.get('stderr') or '')[:300],'| status:',d.get('status'))-except Exception: print(r.stdout[-800:])+    r=subprocess.run(['adom-bridge','--target','adom_drew2','--ai-thread','SolidWorks Bridge',verb,args], capture_output=True, text=True, timeout=tmo+60)+except subprocess.TimeoutExpired:+    print('NO ANSWER: %s did not return within %ds' % (verb, tmo+60)); sys.exit(3)+t=r.stdout; i=t.find('{')+if i<0: print('RAW:', t[:300], '| stderr:', r.stderr[:300]); sys.exit(1)+try:+    d=json.loads(t[i:])+except ValueError:+    print('NOT JSON:', t[i:i+300]); sys.exit(1)+# adom-bridge wraps a routed reply as {"status":..,"result":{...}}; unwrap it, and+# leave anything else exactly as it came.+print(json.dumps(d['result'] if isinstance(d, dict) and isinstance(d.get('result'), dict) else d))