← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.0+0.9.1
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.0"+version = "0.9.1" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.0"+version = "0.9.1" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+64−15
@@ -242,19 +242,32 @@ The suite is built so it cannot lie:   `swx_progress`.  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.+`%LOCALAPPDATA%\adom-solidworks-bridge\` (or `SWX_SCRATCH_DIR` for tests): the+recovery folders, the action ledger, the ownership registry, launch state, verb+timings, settings and the bridge's own exports. It is a directory **only this+bridge writes to**, and it is outside everything ab owns:++- **not ab's install cache** (`Adom Bridge\bridges-cache\solidworks\`, where the+  bridge itself is installed): ab's updater re-extracts it, 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 (measured 2026-09-30);+- **not ab's own directory** (`Adom Bridge\`): its installer and uninstaller own+  it, and what an ab uninstall, repair or reinstall does to anything else in it+  is undocumented. 0.9.0 kept its data in `Adom Bridge\solidworks-data\`;+  0.9.1 moved it out (adversary pass 39).++Both older locations are migrated on start — copied under a temporary name,+verified byte for byte, renamed into place, never over an existing file, the+originals left where they were, each file recorded by size, mtime and sha256, and+copied again beside the first copy if it changes later (see+`migrated-from-legacy.json`). With neither `LOCALAPPDATA` nor `USERPROFILE` the+root would fall back to the OS temp directory, which Windows empties on a+schedule, so every sweep then refuses (`data_root_is_temp`); and a sweep refuses+outright (`recovery_inside_install_dir`) if its recovery folder would land under+any 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@@ -293,7 +306,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-`%LOCALAPPDATA%\Adom Bridge\solidworks-data\recovery\<yyyymmdd-hhmmss>\` and verified on disk+`%LOCALAPPDATA%\adom-solidworks-bridge\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@@ -445,7 +458,43 @@ not.  ## Roadmap -- v0.9.0 (this): **the user's rescued work no longer lives where an update+- v0.9.1 (this): **the data root moves out of ab's directory altogether — and a+  legacy file that changes after its copy is never silently settled again**+  (adversary pass 39). 0.9.0 moved recovery out of the directory ab re-extracts on+  every bridge update and INTO `%LOCALAPPDATA%\Adom Bridge\solidworks-data\` —+  inside the directory ab's own installer owns ("installs to %LOCALAPPDATA%\Adom+  Bridge\", "the uninstaller lives at …\uninstall.exe", per ab's docs), whose fate+  on an ab uninstall, repair or "delete the folder and reinstall" is documented+  nowhere and measured never. The only copy of unsaved work cannot hang on that:+  it is the measured SEV-1's shape, one level up. So the data root is now+  `%LOCALAPPDATA%\adom-solidworks-bridge\`, a SIBLING of `Adom Bridge\` that only+  this bridge writes to (`%USERPROFILE%\adom-solidworks-bridge\` if LOCALAPPDATA is+  missing), and a sweep refuses (`recovery_inside_install_dir`) if its recovery+  folder would land under the install directory, ab's own directory, or either+  older location.++  Both older locations are LEGACY ROOTS now, migrated oldest first — 0.8.x's+  bridge cache, then 0.9.0's `solidworks-data` — under the same rules (temporary+  name, byte check, rename, never over an existing file). And each copy is+  RECORDED by (legacy root, path, size, mtime, sha256 of the copy): 0.9.0 settled+  a file by its name, so a rollback's appends to the old ledger and a rewritten+  ownership registry were never looked at again. A legacy file that changes after+  its copy is now copied AGAIN, beside the first copy (`<name>.legacy-<stamp>`),+  never over it, and reported `changedSinceCopy` — in `migrated-from-legacy.json`,+  in the ledger (`legacy_file_changed_since_copy`), and in `swx_recover_list`'s+  `migration` block. A same-named file from the other legacy root with different+  bytes is kept beside too, never dropped.++  With neither LOCALAPPDATA nor USERPROFILE the root would fall back to the OS+  temp directory, which Windows empties on a schedule: every sweep now REFUSES+  there (`data_root_is_temp`, nothing written, nothing closed) and the start-up+  log says why. `swx_recover_list` reads all three locations (`legacyRecoveryRoots`,+  `location:"legacyDataRoot"`, `atRisk`), keeps `notCopiedYet` at `verified:null`+  (the adversary's pin), and says ONLY A PARTIAL COPY EXISTS — not "gone" — for a+  file whose copy was interrupted. SKILL.md is cut to the rules that never bend,+  with WHEN/trigger cues in its description, and the detail moved into four+  `references/*.md` files that install beside it. 423 tests.+- v0.9.0: **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
SKILL.md+74−577
@@ -1,6 +1,6 @@ --- name: solidworks-bridge-description: Drive SOLIDWORKS desktop CAD from any Adom container via swx_* verbs — create new parts/assemblies/drawings, open and import files, build geometry with a modelling DSL, read the geometry back in millimetres, drive dimensions, export a dozen formats, generate BOMs, and run design analysis (mass properties, interference, rebuild health) — all in the background over the COM API. Verb and arg names mirror the Fusion 360 bridge. Trigger words - solidworks, swx, sldprt, sldasm, slddrw, new solidworks part, create a part, model a bracket, solidworks bom, step export from solidworks, export glb, mass properties, interference check, pack and go, solidworks properties, open in solidworks, solidworks parameters, change a dimension.+description: Drive SOLIDWORKS desktop CAD on the user's Windows PC from any Adom container through swx_* verbs - create parts, assemblies and drawings, open and import files, model with a JSON DSL, read geometry back in mm, drive dimensions, export a dozen formats, generate BOMs, run mass-properties/interference/rebuild checks, and close or quit SOLIDWORKS safely (every open document is saved to a recovery folder first). Use when the user mentions SOLIDWORKS or its files, asks to model, open, import, export or measure a CAD part, wants a BOM or custom properties, or wants SOLIDWORKS closed or quit, or closed work found again. Trigger words - solidworks, swx, sldprt, sldasm, slddrw, new solidworks part, model a bracket, open in solidworks, import step, export step, export glb, solidworks bom, mass properties, interference check, pack and go, custom properties, change a dimension, close solidworks, quit solidworks, recover my solidworks file, recovery folder. ---  # SOLIDWORKS bridge — `swx`@@ -14,590 +14,87 @@ 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+**Trust each verb's own reply over anything written here.** Every reply carries+a `_hint`, and every refusal an `errorCode` that names the rule it applied. -- **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.+## Read the reference for the job -## Closing and quitting: never ask the user to do it+This file holds the rules that never bend. The detail lives beside it, in this+skill's `references/` folder — read the one that matches what you are doing+BEFORE you do it: -- **The bridge never asks you to close.** `swx_shutdown {reason}` saves every open-  document into a recovery folder, closes them all, and quits SOLIDWORKS. No write-  opt-in, no per-document confirmation, no environment flag — the user has already-  authorized this standingly. It needs a reason, because the reason is written for-  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-  `%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-  then edited is copied into recovery under a new name; their file on disk is-  untouched and their unsaved changes still survive.-- **It refuses to start a sweep that will not fit.** Before writing anything it-  estimates the session — by document kind, since an assembly is about thirty-  times the median part — and compares it with free space on the recovery volume;-  if there is less than twice the estimate it answers `insufficient_headroom`-  with the numbers, having written and closed NOTHING. Free some space, or point-  `recoveryDir` at a volume with room. It re-checks before every save, so a disk-  that fills mid-sweep stops it too — what was already written stays written and-  nothing is closed. `headroomBytesBefore` and `estimatedWriteBytes` are in every-  reply. The reason is a truncation nobody can detect afterwards: a recovery file-  can be the RIGHT document CUT SHORT and nothing in the file would show it.-- **"On disk" is checked structurally.** Each recovery file must LOOK like a-  SOLIDWORKS document (the container marker at its head) and be at least as big as-  the smallest real document of its kind (part 24 KB, assembly 80 KB, drawing-  256 KB — and if the bridge could not establish the kind, the strictest of the-  three plus at least the size of the document's own previous file);-  both are reported per document as `recoverySignatureOk` and `recoveryBytes`. A-  full disk leaves zero-byte and truncated files that `exists()` and any byte-  floor accept; those are not saves, and the document stays open. The check fails-  CLOSED — anything unrecognised leaves the document open rather than closing it-  against a file nobody can vouch for.-- **A save prompt cannot appear** for the reason a close would raise one, because-  nothing is unsaved by the time a close happens. But a SAVE can raise one of its-  own (a missing external reference, a rebuild question), so the window census-  runs before EVERY save and before EVERY close. If ANY dialog appears the sweep-  stops where it is, reports it in `stoppedByDialog`, and does nothing further.-  It is never answered. Everything already written stays written.-- **If the window census cannot be READ, nothing closes.** Unknown is not "no-  dialog". The saves still run — saving loses nothing — but no document is closed-  and the reply carries `stoppedBy:"dialogCensusUnknown"`,-  `dialogCensusReadable:false`, `done:false`. The work is already safe in the-  recovery folder; tell the user that, and run it again when `swx_status` shows-  the session is readable.-- **`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. **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.-- **What did NOT change.** `swx_stop` still quits only a session with zero-  documents open; the ladder still refuses on a dialog, still measures the exit-  from the process census, and still never kills anything. The authorization moved-  the consent question, not the data-safety guarantees.--## Install, launch, sign-in--- **This edition cannot be launched by COM, so do not try.** The install here is-  SOLIDWORKS Connected / Makers (3DEXPERIENCE), and `swx_status` reports-  `canLaunchProgrammatically:false`. COM can ATTACH to a session that is already-  running; it cannot create one. That is a property of the edition, not a-  limitation of the bridge, so there is no argument or retry that changes it.-- **The launch that works is the Platform's own shortcut.** `swx_start` invokes-  the 3DEXPERIENCE Platform `.lnk`, which starts `CATSTART.exe`, which starts-  `SWXDesktopLauncher.exe`, which starts `sldworks.exe`. The launcher chain then-  exits — once `sldworks.exe` is up there is no launcher process left to poll or-  to reap.-- **The `shellExecute` route hands back a real handle, proven live 2026-09-11.**-  It invokes through `ShellExecuteExW` with `SEE_MASK_NOCLOSEPROCESS`; the live-  run got a process handle back (CATSTART pid 131272, creation time 1789148101)-  and that chain reached the launcher's `Run Begin`. This matters because the-  bridge's law is **no handle, no kill**: it may only clean up a chain the invoke-  itself handed back. On this machine that world is confirmed.-- **`stage:"helperSignIn"` means a human has to type a password — you cannot.**-  A cold launch can stop at the 3DEXPERIENCE ID window: a NATIVE WebView2 window,-  class `DSYSysLoginPanelWebView2Class`, title-  "Login | 3DEXPERIENCE ID | Dassault Systemes", owned by `SWXDesktopLauncher.exe`-  in the bridge's own chain. UIA sees NO controls inside it, so nothing can read-  or fill it — this bridge included. As of 0.6.0 the bridge recognises the window,-  reports `helperSignIn`, PAUSES every launch deadline while it is on screen (a-  waiting human is not a stall), never kills or escalates the chain, and toasts-  the user that sign-in is needed. Your move is to keep polling and, if the user-  asks what to do, tell them to sign in and to tick **Remember me** once so later-  launches do not stop here. The bridge never asks for, stores or transmits a-  password.-- **How long `helperSignIn` lasts depends on the user answering the window**, not-  on any timer the bridge owns. Do not convert it into a failure.-- **Read `launch.stage` and act on it**, from `swx_status` or from `swx_start`:--  | `stage` | What to do |-  |---|---|-  | `invoked` | The route was just invoked. Poll `swx_status`. |-  | `launcherStarted` | Chain running, `Run Begin` not reached. This is the stage that stalls; the bridge cleans up and tries the next route itself. Do not invoke the shortcut any other way. |-  | `runBegin` | The chain is really building. Keep polling; `sldworks.exe` appears within seconds. |-  | `helperSignIn` | Only the user can clear it. Poll; tell them to look at their screen. |-  | `sldworksUp` | The application exists, COM does not answer yet. Keep polling. |-  | `attaching` | Polling for the COM server. Keep polling; do not launch again. |-  | `attached` | COM is usable. This is the only success — carry on. |-  | `stalled` | That route stalled and its chain was cleaned up. The bridge is trying the next route. Nothing for you to retry. |-  | `exhausted` | Every route was tried and none attached. ASK THE USER to start SOLIDWORKS from the Start Menu or the Platform. |--  The three milestones that matter operationally are **`runBegin`** (the chain is-  really building), **`sldworksUp`** (the application exists) and **`attached`**-  (COM is usable, and the only success).-- **`stillLaunching:true` means a launch this bridge started is still in flight.**-  Calling `swx_start` again does NOT start a second chain — it attaches to the one-  in flight. That is deliberate: four launcher chains once ended up on a user's-  desktop because a caller retried. Polling with `swx_start` is therefore safe,-  and a second invocation by any other route is not.-- **`swx_stop` quits ONLY a session with zero documents open, and `force` does not-  waive that** (`force` waives the UNSAVED question only). If documents are open,-  close them first, or drain leaked conversion temporaries with-  `swx_close_import_temps`.-- **Treat a quit as close to a one-way door.** On this edition relaunching after a-  quit takes 1-3 minutes and can stop at the sign-in window again, which only the-  user can answer. Do not quit to tidy up.--## Is it ready?--- `bridge_readiness` (ab-level, never spawns anything) answers "is SOLIDWORKS-  installed / is the bridge warm" — use it for what-do-I-have questions.-- `swx_readiness` is the bridge's own read-only probe; `swx_status` is the-  everyday state check (detection + COM attach + open docs);-  `swx_get_app_state` is the one to POLL while something long runs.-- **If SOLIDWORKS is not installed** verbs return-  `errorCode:"host_app_not_installed"`. Dassault publishes NO silent-  installer — desktop trial serials come from a reseller, the Makers edition-  from the 3DEXPERIENCE Platform store. Don't tell the user to go download it;-  surface the returned `_hint`.-- **If SOLIDWORKS is not running, `swx_start` starts it — including the-  Connected editions.** Pass a `reason` (it is written for the person whose-  desktop this is, and it is the only thing this verb needs — starting an-  application creates nothing and destroys nothing).-  - On classic desktop installs it attaches over COM.-  - On **3DEXPERIENCE / Connected** editions (including *Design Professional-    for Makers*) it invokes **the shortcut the Platform installed**, which runs-    `CATSTART.exe -run SWXDesktopLauncher.exe` with that user's tenant and-    registry. That is the launch Dassault sanctions.-  - **A cold Connected start runs 1-3 minutes** and may stop at a 3DEXPERIENCE-    sign-in only the user can answer. `swx_start` returns-    `started:true, stillLaunching:true` while it is coming up — that is **not a-    failure**. Poll `swx_status` until `running:true`, or call `swx_start`-    again, which ATTACHES to what is already coming up. `waitSeconds:<n>` (up-    to 240) blocks instead of polling. **Never invoke the launcher any other-    way meanwhile** — two launches means two sign-in windows on somebody's-    desktop.-  - **Calling `swx_start` during a launch launches NOTHING** (v0.4.0). It-    answers `stillLaunching:true, launchedNow:false` with the live `stage`-    (`launcherStarted` / `runBegin` / `helperSignIn` / `sldworksUp` /-    `attaching`), its age, and what to do — and it needs no `reason` while an-    attempt is in flight, so polling with it is always safe. `swx_status`-    carries the same `launch` block.-  - **A stalled launcher recovers itself.** The launcher chain sometimes starts-    and then stops at `SecurityContext:` without ever logging `Run Begin` — it-    then sits idle forever and SOLIDWORKS never appears. A supervisor inside-    the bridge watches the launcher's OWN log, and on a stall it ends **only-    the `CATSTART`/`SWXDesktopLauncher` chain it started itself** and tries the-    next launch route, remembering whichever one works. **There is nothing for-    you to retry, and nothing for you to clean up.**-  - **NO HANDLE, NO KILL — and the same rule for evidence.** A launcher log is-    read as this bridge's own only when the launcher's own writing-thread id-    resolves to a process in our chain, or it is the sole candidate with no-    other launch running; otherwise `unattributedLog:true` and it is not used.-    On a route that cannot say what it started, no log is attributed at all. One consequence worth knowing:-    if SOLIDWORKS comes up on `explorer` or `cmdStart`, the launch SUCCEEDS and-    the route is never remembered — `attached` proves SOLIDWORKS is usable, not-    that our route did it.-  - **NO HANDLE, NO KILL.** A process is only ever ended when the INVOKE ITSELF-    handed back its root (`ShellExecuteExW`'s process handle, or-    `Start-Process -PassThru`'s pid) and it descends from that root. The-    `explorer` and `cmdStart` routes cannot say what they started, so a stall on-    either reports `cleanup:"notAttributable"` and **terminates nothing** — the-    response says a launcher chain may remain that the bridge cannot identify,-    which the user can end from Task Manager. If a route leaves something the-    bridge cannot account for, the machine STOPS rather than starting another-    chain beside it.-  - **If SOLIDWORKS is running but unreachable, `swx_start` will not start a-    second one.** It answers `host_app_hung` (Windows says the window is not-    responding AND a COM call exceeded its budget — only the user can end it),-    `host_app_exhausted` (**`NewDocument` came back with no document** — the one-    signal that proves it; close the bridge's own documents, then ASK THE USER-    to restart, and do not retry in a loop), or `host_app_com_unreachable`-    (usually a sign-in window: ask the user to look at their screen).-    `swx_status.solidworks.memoryAdvisory` reports a very large session as an-    OBSERVATION — a 13 GB SOLIDWORKS can be perfectly healthy, and this bridge-    no longer calls one out of room on a threshold.-  - **"Not responding" is not "dead".** `swx_status.solidworks.verdict` is-    `busy` / `dead` / `unknown`, from the CPU and memory DELTA across two reads-    (`cpuActiveSeconds`, `memoryDeltaMB`). A session at 17.9 GB was-    not-responding with a COM timeout for five minutes while burning 70% of a-    core — and it recovered. `busy` means **tell the user to wait**; only `dead`-    (no CPU over ≥15s, memory flat, no dialog) means only they can end it. One-    reading never says dead — poll again.-  - **After a failed attempt the bridge waits** (`host_app_launch_backoff`,-    growing with each consecutive failure) and then tries ONE route rather than-    re-sweeping the plan. Retrying in a loop is how four launcher chains ended-    up on somebody's desktop; the honest move while it waits is to ASK THE USER-    to start SOLIDWORKS from their Start Menu.-  - **Crash-reporter recovery ships OFF** (`launch.crashRecovery:"off"`). When-    armed it clicks the crash reporter's "Restart SOLIDWORKS Design" button-    once — but only on a window OWNED by the SOLIDWORKS being launched, and only-    when ab structurally confirms the button exists. It is disabled by default-    because the reply shape it reads has never been seen on a real desktop.--- **NEVER kill the 3DEXPERIENCE launcher daemons.** `3DEXPERIENCELauncher.exe`,-  `3DEXPERIENCELauncherSysTray.exe` and `3DEXPERIENCELauncherService.exe` are-  the Platform's own background services and carry the user's Platform session.-  They are **not required** for a launch (a successful launch on 2026-09-04 ran-  with no Backbone at all), so killing one buys nothing and breaks something-  that is not yours — and **a real user launch rebuilds them**, which is the fix-  if they are genuinely broken. The bridge's own kill allowlist is exactly two-  images, `CATSTART.exe` and `SWXDesktopLauncher.exe`, and only instances it-  created itself, terminated by pid. Do not reach for `taskkill` /-  `process_kill` against any of this yourself; `sldworks.exe` included (that is-  `swx_kill`, and it is gated behind the user's explicit opt-in).-  - `errorCode:"host_app_launch_blocked"` now means only one thing: **the-    bridge could not FIND a shortcut**, not that the edition cannot be-    launched. `swx_status` reports `solidworks.shortcut` and-    `solidworks.launchRoute` so you can see what it found; if the user has a-    shortcut elsewhere, `SWX_SOLIDWORKS_SHORTCUT=<path to the .lnk>` points the-    bridge at it. Do not run `sldworks.exe` yourself via `shell_execute` or-    `launch_process` — launching it DIRECTLY, bypassing the launcher chain, is-    what produces the *"SOLIDWORKS Design must be launched from the-    3DEXPERIENCE Platform"* modal.--## Making something new--```bash-adom-bridge --ai-thread "<thread>" swx_new_document '{"type":"part"}'-adom-bridge --ai-thread "<thread>" swx_run_script '{"script":[-  {"op":"sketch_rect","plane":"Front Plane","widthMm":50,"heightMm":30},-  {"op":"extrude","depthMm":20},-  {"op":"set_material","name":"6061 Alloy"},-  {"op":"set_prop","name":"PartNo","value":"BRK-100"}-]}'-adom-bridge --ai-thread "<thread>" swx_inspect_bodies '{}'      # verify the GEOMETRY-adom-bridge --ai-thread "<thread>" swx_save_as '{"outputPath":"C:\\parts\\bracket.sldprt"}'-```--- `swx_new_document` gives you an **unsaved** document: it has a title (`Part1`)-  and **no path** until `swx_save_as`. Target it by `name`, not by path.-- `swx_run_script` takes a JSON **array of steps**, not a string of code —-  SOLIDWORKS has no in-process scripting host the way Fusion has Python. Every-  dimension is in **millimetres**. A rejected script returns the full op list-  and a worked example, so read the error.-- **Verify with `swx_inspect_bodies`, not with the step list.** It reports each-  solid's bounding box, size, volume, area and mass in millimetres — that is-  what tells you the model is what you meant.-- Throwing it away is `swx_close_doc {"name":"Part1","save":false}`, or-  `swx_reset_workspace` to sweep every scratch document the bridge created.-- **`swx_import` opens one unsaved document and it STAYS OPEN** until you close-  or save it — exactly like `swx_new_document`. It reports `documentsOpened:1`-  and `netCountOk:true`. Importing in a loop without closing is what leaks-  documents (a live session was found holding 1342 STEP temps, 8 GB). If a count-  ever looks huge, `swx_docs` will say `truncated:true` with a `count` and a-  small `sample` instead of timing out — run **`swx_close_import_temps`**-  `{"reason":"…"}` to drain the leaked `…\Temp\swx<pid>\IC~~\…` conversion temps.-  That verb works in the default posture and can never touch a saved user-  document (a user file has a real path; a temp does not).+| When you are about to… | Read |+|---|---|+| close documents, quit SOLIDWORKS, drain leaked imports, or tell the user where their closed work is | `references/closing-and-recovery.md` |+| start SOLIDWORKS, wait through a launch or a 3DEXPERIENCE sign-in, or decide whether it is ready | `references/launch-and-sign-in.md` |+| write anything (save, export over a file, set properties, run a script or macro, close someone's document) or touch a dialog or window | `references/writes-and-dialogs.md` |+| model, import a STEP/IGES, export, or map a Fusion 360 habit onto SOLIDWORKS | `references/modelling-and-export.md` |++## The rules that never bend++1. **The bridge never asks you to close, and you never ask the user to.**+   `swx_close_all_safely {reason}` saves every open document into a recovery+   folder, verifies it there, and only then closes it; `swx_shutdown {reason}`+   does the same and then quits SOLIDWORKS. The user has authorized this+   standingly: no write opt-in, no per-document confirmation, only a reason. A+   document that could not be written is NOT closed and is named in `remaining`.+   Recovery lives in `%LOCALAPPDATA%\adom-solidworks-bridge\recovery\<timestamp>\`,+   a directory only this bridge writes to.+2. **Tell the user what the sweep did, from `swx_recover_list` — never from+   memory.** Check `verified` on EVERY file (`false` means the document was left+   OPEN and that work is still in SOLIDWORKS; `null` means UNKNOWN), and follow+   each file's own `howToOpen` (a `.sldrecover` rescue must be renamed before+   SOLIDWORKS will open it). A clean, saved document got no copy: it is under+   `closedInPlace`, and "closed at its own path, untouched" is only true when+   `untouched` is `true`. Sweeps in older locations carry `atRisk:true` — say so.+3. **Never answer a dialog, never kill anything.** A dialog stops every sweep,+   quit and import where it is (`stoppedByDialog`) and is never answered: show+   the user `swx_state`'s screenshot and let them answer. Never use `taskkill` or+   `process_kill` on SOLIDWORKS, its launcher chain or the 3DEXPERIENCE daemons.+4. **Writes are deny-by-default, and you cannot grant the opt-in yourself.** The+   bridge refuses to change a document it did not create or to write over an+   existing file (`user_writes_disabled`, `destination_exists`). Exporting to a+   path that does not exist yet always works. Anything beyond that needs the+   user — read `references/writes-and-dialogs.md` first.+5. **Never change the user's SOLIDWORKS settings, and do not ask the bridge to.**+   It reads 3D Interconnect and never writes it, not even temporarily. If+   `swx_import` fails with `interconnect_blocks_import`, that is the whole+   answer: tell the user to uncheck Tools > Options > System Options > Import >+   "Enable 3D Interconnect" and import again.+6. **Start SOLIDWORKS only with `swx_start`, and poll it.** A cold start of the+   Connected edition takes 1-3 minutes; `stillLaunching:true` is not a failure,+   and calling `swx_start` again attaches instead of launching twice.+   `stage:"helperSignIn"` means a person must sign in — only the user can. Never+   launch `sldworks.exe` or the launcher any other way.+7. **Name the document you mean.** Pass `targetDocument` (a title) or `path` on+   every verb that changes something; the active document follows window focus,+   and the user may click something else mid-run.+8. **`swx_stop` quits only a session with zero documents open**, and `force`+   never waives that. Treat a quit as close to a one-way door: relaunching takes+   minutes and can stop at the sign-in window again.  ## The everyday flow -1. `swx_find_files {"roots":["C:\\designs"],"query":"gearbox"}` — locate-   documents on disk (works with SOLIDWORKS closed; newest first).-2. `swx_open {"path":"...sldasm"}` — silent open, background. SOLIDWORKS-   launches automatically if closed (cold start 30-90s; the verb's `progress`-   block and `swx_progress` report it).-3. Work the document:-   - `swx_bom {"format":"csv"}` — quantities per (file, configuration) with-     every custom property as a column; `csvPath` lands on the DESKTOP disk.-   - `swx_mass_properties {}` — mass/volume/area/CoM in SI units.-   - `swx_interference {}` — assembly clash check (`count:0` = clean).-   - `swx_rebuild_check {}` — force rebuild + broken-feature census.-   - `swx_props_get` / `swx_props_set` — custom properties (then `swx_save`).-   - `swx_export_step {"outputPath":"C:\\out\\part.step"}` — or-     `_iges/_stl/_3mf/_glb/_sat/_parasolid/_pdf/_dxf/_dwg`, or the generic-     `swx_export {"format":"..."}`. **Verified live from a part:** STEP, IGES,-     STL, X_T, X_B, SAT, 3MF, GLB, PLY, AMF, 3DXML, PNG, JPG, TIF, PDF. DXF,-     DWG, OBJ and U3D are **refused for a part** (DXF/DWG are drawing formats).-     VRML is blocked by the bridge — it hangs SOLIDWORKS on a modal.-   - `swx_design_info {}` / `swx_inspect_bodies {}` / `swx_get_parameters {}` /-     `swx_set_parameter {"name":"D1@Boss-Extrude1","valueMm":25}` — read the-     design back and drive it.-   - `swx_pack_and_go {"out":"C:\\handoff"}` — self-contained copy with all-     references, for sending a design anywhere.-4. `swx_state {}` any time — open docs + active doc, the look-then-act check.--## Rules and gotchas--- **Target docs by `path`**, not by activating them — every doc verb takes an-  optional `path` and falls back to the active document.-- **Files land on the DESKTOP filesystem.** From a cloud container, pull-  exports back with ab's `pull_file`.-- **Background etiquette:** the bridge never foregrounds SOLIDWORKS; responses-  carry `windowEtiquette:"background"`.-- **A modal dialog inside SOLIDWORKS blocks every verb** (COM is single-  threaded). If verbs time out, ask the user about a dialog, or check-  `swx_status`. The bridge holds the COM object in a separate child process, so-  a wedged call is killed and replaced rather than taking the bridge down — the-  verb after a timeout works again.-- **`swx_props_set` changes memory, not the file** — follow with `swx_save`.-- `swx_com_eval {"expr":"...","reason":"..."}` is the maintainer escape hatch:-  a late-bound COM call chain rooted at `$sw` (the application) or `$doc` (the-  active document), e.g. `$sw.RevisionNumber()` or-  `$doc.Extension.CustomPropertyManager("").GetNames()`. String, number and-  boolean literal arguments. For prototyping a capability against a real-  install before it becomes a verb; `reason` is required.-- `swx_screenshot` delegates to ab's generic `desktop_screenshot_window`;-  `swx_state {}` bundles that with the dialog census and this machine's-  measured verb timings in one call (`screenshot:false` to skip the callbacks).-- **Do not dismiss dialogs casually.** `swx_dismiss_blocking_dialogs {}`-  REPORTS by default. Closing needs `close:true` + `reason` + `confirm`; a-  "Save changes?" / Document Recovery / licence dialog is never closed at all;-  and a window that cannot be PROVED to belong to SOLIDWORKS is never closed-  either (ab's window list carries no owner pid, so a bare dialog class could be-  any application's). `swx_close_window` refuses the same way, with-  `errorCode:"window_not_attributed"`.-  Closing a stuck SOLIDWORKS modal through the window manager has been observed-  to take the whole application down — and on Connected editions the bridge-  cannot start it again.-- **☠ THE BIG ONE: writes are DENY-BY-DEFAULT.** The bridge refuses to change-  any document it did not create, and to write over any file that already-  exists. That includes things that do not look like writes: `swx_props_set`-  (it dirties the document, so the user's own Ctrl+S would commit it),-  `swx_rebuild_check` (it calls `ForceRebuild3`), `swx_bom {format:"csv"}` (it-  writes a file), `swx_pack_and_go`, and `swx_stop` /-  `swx_close_all_documents` / `swx_close_doc` (they end or disturb the user's-  session).-  Check `swx_status.userWrites` (`"disabled"` / `"enabled"`) before planning-  anything that writes. Refusals are `errorCode:"user_writes_disabled"` or-  `"destination_exists"`; the response lists `bridgeDocuments` (each with the-  `path` that names it unambiguously) so you can retarget. **Exporting to a path-  that does not exist yet, from any document, always works** — that is the-  normal way to get data out. To reach the user's own document or overwrite a-  file, the bridge process must have been started with-  `SWX_ALLOW_USER_WRITES=1` AND the call must carry `allowUserWrites:true` +-  `confirm:true` + `reason`. **You cannot grant this to yourself — ask the-  user.** Every exception is recorded in `swx_action_ledger`.-- **The affected verbs:** `swx_save`, `swx_save_as`, `swx_export*`,-  `swx_bom {format:"csv"}`, `swx_pack_and_go`, `swx_close_doc`,-  `swx_props_set`, `swx_set_parameter`, `swx_set_preference`,-  `swx_run_script`, `swx_run_macro` (**always** — see below),-  `swx_rebuild_check`, `swx_stop`, `swx_kill`,-  `swx_close_all_documents`, `swx_open {readOnly:false}`, and BOTH dialog-closing-  verbs — `swx_dismiss_blocking_dialogs {close:true}` and `swx_close_window` —-  for any dialog at all, including ones the bridge itself raised. **Reading is never gated**, and-  neither is exporting to a path that does not exist yet.-- **`swx_run_macro` is gated ALWAYS, with no safe case.** A macro is a VBA-  project with the whole SOLIDWORKS API in scope and the filesystem behind it:-  it can model, save over anything, batch-export, delete, or quit, to documents-  this bridge has never seen — and nothing about the *call* predicts which.-  So the two questions that bound every other write ("is the document ours",-  "is the destination clear") do not apply, and running one needs the full-  opt-in chain every time, even for a macro the bridge itself shipped. In the-  default posture it refuses. `module` and `procedure` default to `main`/`main`-  (the macro recorder's defaults); the macro is unloaded after the run, so an-  edited `.swp` runs its new code. **`swx_run_macro` has not yet been run-  against a live SOLIDWORKS** — its response says so (`provenLive:false`), and-  the bundled `resources/macros/adom-hello.swp` is a placeholder, not a working-  macro.-- **What the opt-in actually opens.** When the user HAS opted in, in full, the-  bridge does the thing — it does not refuse anyway. `swx_close_doc` and-  `swx_close_all_documents {closeUnsaved:true}` will close and discard the-  user's own unsaved documents on that chain, with each one named in-  `swx_action_ledger`. Both the server and the com host check the identical-  chain independently, so a partial chain is refused twice and a complete one-  is honoured by both.-- **`swx_close_all_documents` accounts for everything it leaves open.** Its-  `skipped` array names every document still open when it returns, with why —-  and a document whose dirtiness SOLIDWORKS will not report is treated as-  UNSAVED, not clean. Reaching this verb at all needs the write opt-in, since-  it ends the user's session; a refusal closes nothing.-- **`swx_stop` quits ONLY an empty session, and proves the exit from the process-  census.** It is a measured ladder, pinned at rung (a) (`UserControl=false;-  ExitApp; release every COM reference`); rungs (b)-(f) exist and need an-  explicit `maxRung`, and none of them has ever run against a real SOLIDWORKS.-  Before it tries anything it re-measures seven preconditions, and **any one of-  them coming back UNKNOWN refuses** (`stop_preconditions_unknown`) — an-  unanswerable question is not permission. The commonest refusal is-  `open_documents_present`: **documents open means no**, and `force` does not-  waive it (`force` waives the UNSAVED question only). Success is *this pid with-  this creation time absent from the census* — never what `ExitApp` returned —-  and nothing the bridge owns is disowned until that is true, so a declined quit-  leaves you still able to save and close your own documents. If a dialog-  appears the ladder stops permanently and reports it-  (`stop_blocked_by_save_prompt` / `stop_blocked_by_dialog`): **this bridge never-  answers a dialog** — show the user `swx_state`'s background screenshot and let-  them answer. A decline is `success:false` with-  `errorCode:"host_app_stop_declined"` and the whole `rungsTried` list. **Do not-  reach for `swx_kill`**: it discards exactly the work the dialog is asking-  about, and on a Connected edition relaunching takes 1-3 minutes and can fail.-  `expectedSurvivorsAlive` (the 3DEXPERIENCE tray daemons, `ENOPLMCSAClient`,-  `EdmServerV6`) is CONSENT, not a leak — never end one. Every precondition is-  re-measured before EVERY rung, so a multi-rung ladder can refuse in the middle-  (`refusedMidLadder:true`) if a document appears while a rung is settling, and it-  never continues against a different process (`stop_identity_changed`).-- **On a flooded session, every list is a SAMPLE and says so.** Above the-  enumeration cap `swx_docs` returns `{count, truncated:true, sample,-  importTempsInSample, importTempsInSampleOf}` and NO `docs` list — it walks ten-  documents, it does not enumerate thousands (doing that killed the com host on a-  live 2144-document session). `swx_close_all_documents` on such a session still-  closes, but reports `closed`/`remaining` as **null** with counts, and names the-  unknown in `skipped`. Never read a null list as "nothing there".-- **A destination this bridge cannot CHECK is not free.** Every write is gated on-  whether something is already at the destination, and that question has three-  answers: occupied (needs the opt-in), free (a bridge-created document may be-  written there without one), and **unverifiable** — a path this process cannot-  stat, which refuses with `destination_unverifiable` and is NOT waivable by the-  opt-in. It is not a permission question. The bridge and SOLIDWORKS do not share a-  filesystem view, so a UNC path the bridge cannot see may hold a file SOLIDWORKS-  would overwrite. Check it yourself, or write somewhere the bridge can see.-- **`close_outcome_unknown` means a HUMAN has to look.** It is the drain's third-  outcome: a close reported success, the document is still open, and the count could-  not be read — so it cannot be told whether nothing closed or something else did.-  The drain stops. Do not retry it; ask the user what is on their screen, and read-  `closeFailed` for the path and the count immediately before the close.-- **A drain that says `done:false` is not finished — read `walkStop`.** The drain-  looks at a bounded WINDOW per call (`documentsScanned`), and `done:true` means the-  walk reached the END of the session with nothing left. `walkStop` is-  `reachedEnd` / `hitMax` / `timedOut` / `iterationFailed`, and-  `titleCollisionsCheckedOver` says how many documents the collision check actually-  covered — a same-titled document beyond the window is not in that list. Call it-  again until `done:true`.-- **The drain never closes a document whose title another open one shares** —-  compared the way `CloseDoc` resolves a name, which on Windows is-  case-insensitively. If the open set cannot be enumerated it does not attempt the-  registry sweep at all (`ownedUnsavedSweepBlind:true`, `done:false`): it cannot-  tell whether a title is shared, and a title-only document can only be closed by-  name. It-  closes by full path and verifies each close, but `CloseDoc` resolves by name — so-  a shared title is refused and reported in `titleCollisions` (with every path that-  shares it) rather than letting SOLIDWORKS pick. The user decides: close it-  themselves, or save the other document under another name. `done:false` while any-  collision stands.-- **`swx_close_import_temps` needs a resolved `%TEMP%`, and says so.** The drain-  identifies a temp by its LOCATION under this user's resolved temp directory —-  that path is the whole proof, because the close does not prompt. A path that-  merely looks like a temp (a restored profile backup, another user's profile) is-  not enough, and when `%TEMP%` cannot be resolved the verb refuses with-  `temp_root_unknown` rather than reporting that it found nothing to close. Read-  `tempRoot` and `importTempsElsewhere` in the reply: `closed:0` with-  `importTempsElsewhere:N` means N documents look like leaked temporaries but sit-  under a directory this bridge could not resolve (ab can run under a different-  profile or a redirected TEMP), so the drain is NOT done — `done:false` — and the-  hint says how to point it at the right root.-- **`swx_close_window` is gated exactly like `swx_dismiss_blocking_dialogs`.**-  It refuses an application window (`errorCode:"window_is_application_frame"` —-  closing a SOLIDWORKS frame quits it, which is `swx_stop`'s job); it refuses a-  dialog that asks about the user's own work at any posture-  (`errorCode:"dialog_belongs_to_the_user"` — Save changes?, Document Recovery,-  licence); and closing anything else needs the process-level write opt-in.-  `swx_window_info` marks every window with `isFrame` and `closable`, and-  `closable` already folds in all three refusals — trust it.-- **`swx_screenshot` captures SOLIDWORKS' own window, by handle.** A free-text-  `titleContains` is ignored; pass `hwnd` only if `swx_window_info` attributes-  it to SOLIDWORKS. For anything else on the desktop, ab's own-  `desktop_screenshot_window` is the right tool and carries the user's approval-  prompt.-- **`swx_open` is READ-ONLY by default, and `readOnly:false` is a WRITE.** A-  read-write open takes a write lock on the user's file and converts-and-dirties-  an older-version document in memory, so it goes through the same gate as every-  other write. Reading is always free. `swx_close_doc {save:false}` on a DIRTY document the bridge-  did not create additionally needs `confirm:true` + `reason`-  (`errorCode:"unsaved_documents"`) — and if SOLIDWORKS will not say whether the-  document is dirty, it is treated as dirty.-  **There is no undo.** Do NOT reach for `swx_close_doc {save:false}` to "undo"-  a change to someone's document: that discards the entire document, not your-  edits. Undo belongs to the user, in SOLIDWORKS.-- **Destructive verbs ask first.** `swx_close_all_documents`, `swx_close_window`,-  `swx_kill` and `swx_close_doc`-on-dirty-work need `confirm:true` (and-  `swx_close_window`/`swx_kill` a `reason`). `swx_stop` and `swx_kill` refuse-  outright while documents are unsaved (`errorCode:"unsaved_documents"`) — pass-  `force:true` only if the user has said the changes are disposable.-  `swx_close_all_documents {closeUnsaved:true}` additionally needs a `reason`,-  and it discards ONLY the bridge's own scratch documents: the user's unsaved-  ones are left open and listed in `skipped`.-- **`swx_props_set` IS gated now.** The old justification — "in memory until a-  gated save" — ignored the persist route that cannot be gated: the user. It-  leaves their document dirty, so SOLIDWORKS prompts "Save changes?" on close-  and their own Ctrl+S writes bridge-authored data into their file.-- **Name the document with `targetDocument`, not by hoping.** Every mutating-  verb resolves its target ONCE — `targetDocument` (a title), else `path`, else-  the active document — and the bridge acts on exactly that. A `targetDocument`-  naming nothing open is an error (`document_not_open`), never a silent fall-  back to whatever the user has in front of them; naming two different documents-  two ways is `ambiguous_target`. Prefer `targetDocument` whenever it matters:-  SOLIDWORKS' active document follows window focus, and the user may click-  something else mid-run.-- **`swx_com_eval` evaluates READS ONLY, by allowlist.** Members beginning-  `Get`/`Is`/`Has`/`Can`/`Enum`, plus a short list of navigation properties-  (`ActiveDoc`, `Extension`, `SketchManager`, `FeatureManager`, `Frame`,-  `Name`, `Value`, `Mass`, …). Everything else — including members nobody has-  thought of — is refused with `errorCode:"eval_member_blocked"` and a reason.-  It is an allowlist rather than a banned list because a banned list has to-  anticipate every dangerous member of an API with thousands, and the first one-  missed `SendMsgToUser2` (a modal that wedges the host), `SetUserPreferenceToggle`-  and `ForceRebuild3`. If a genuine READ is refused, that is a bug worth filing.-- **`swx_save_as` RENAMES by default.** The open document becomes that file and-  takes its name — which is what you want for a scratch part, and emphatically-  not what you want on someone's `bracket.sldprt`. `copy:true` writes the file-  and leaves the document alone; that is what every `swx_export*` does.-- **`swx_reset_workspace` is the safe cleanup**: it closes only documents this-  bridge process created and has not saved. The list lives in memory, not on-  disk, because SOLIDWORKS names new documents `Part1`/`Assem1` and a-  remembered title would eventually match a document the USER made. A bridge-  restart therefore forgets and the sweep closes nothing.-- **The export format is the DESTINATION EXTENSION, not the `format` argument.**-  `swx_export_step {"outputPath":"x.wrl"}` writes VRML, not STEP — which is why-  the bridge refuses `.wrl` on the filename (`errorCode:"format_blocked"`) on-  every route. VRML raises a modal that hangs SOLIDWORKS for minutes.-- **There is no cloud.** `swx_cloud_status` explains why with a live probe: the-  SOLIDWORKS COM API exposes no 3DSpace/3DDrive member at all. Local files work-  fully. Do not go looking for a cloud-open verb.--## Coming from the Fusion 360 bridge?--Arg names and response keys mirror `fusion_*` wherever the concept survives:-`outputPath`, `filePath`, `name`, `parameters`, `bodies`, `current`, `applied`,-`actions`, `stages`, `wouldPrompt`, `closed`, `remaining`. The four things that-do **not** transfer:--| Fusion | SOLIDWORKS |-|---|---|-| `fusion_start` launches the app | `swx_start` **attaches only** on Connected editions — a human must start it |-| `fusion_run_modeling_script` takes Python | `swx_run_script` takes a JSON array of typed steps |-| Fusion has no macro road | `swx_run_macro` runs a VBA `.swp`/`.swb`/`.dll` — always gated, and not yet live-proven |-| 21 cloud verbs (APS) | none — `swx_cloud_status` says why |-| `fusion_export_optimized_glb` needs a service | `swx_export_glb` is native |--`docs/FUSION-PARITY-REPORT.md` in the bridge repo has all 132 Fusion verbs with-a verdict.+1. `swx_status {}` — is SOLIDWORKS installed, running, attached?+   (`swx_start {reason}` if it is not.)+2. `swx_find_files {"roots":["C:\\designs"],"query":"gearbox"}` — locate+   documents on disk, newest first (works with SOLIDWORKS closed).+3. `swx_open {"path":"...sldasm"}` — silent, background, READ-ONLY by default.+4. Work the document: `swx_bom`, `swx_mass_properties`, `swx_interference`,+   `swx_rebuild_check`, `swx_props_get`, `swx_inspect_bodies`,+   `swx_get_parameters`, `swx_export {"format":"step","outputPath":"..."}`.+5. Make something new: `swx_new_document {"type":"part"}`, then+   `swx_run_script` with a JSON array of steps in millimetres, then verify with+   `swx_inspect_bodies` — see `references/modelling-and-export.md`.+6. `swx_state {}` any time — open documents, the active one, the dialog census+   and a background screenshot, in one call.+7. Done: `swx_reset_workspace` closes only what the bridge created and has not+   saved; `swx_close_all_safely {reason}` empties the session safely.++Files land on the DESKTOP filesystem; pull exports back with ab's `pull_file`.+The bridge never foregrounds SOLIDWORKS.  ## Verb catalog  `swx_describe '{}'` returns the full machine-readable catalog with per-verb risk, the modelling DSL's op list, and the live-verified export-format matrix.-Trust each verb's own `_hint` over anything written here.
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.0",+  "version": "0.9.1",   "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",
docs/ECOSYSTEM-TABLE.md+2−2
@@ -112,10 +112,10 @@ templates**, alisamsam **22**. | 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 | ✗ | ✗ | ✗ | ✗ | ✗ |  | 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 | ✗ | ✗ | ✗ | ✗ | ✗ |+| 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`). **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 | **Closes its own documents without asking — and loses nothing doing it** (preserve-then-close: every open document written to a recovery folder and verified there BEFORE anything closes) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (0.7.7)** — `swx_shutdown {reason}` / `swx_close_all_safely {reason}` save every open document into `%LOCALAPPDATA%\adom-solidworks-bridge\recovery\<timestamp>\` (0.9.0: `Adom Bridge\solidworks-data\…`, inside the directory ab's installer owns; before that `…\bridges-cache\solidworks\recovery\…`, inside ab's install cache, which an update empties: row 49), verify it, close parents before children, then quit; no write opt-in, no per-document confirmation (Drew, 2026-09-11: *"I despise every time you ask for help closing"*). The authorization is to CLOSE, not to lose anything, so the consent question is replaced by checkable rules: the sweep REFUSES TO START when the recovery volume cannot hold the session it is about to write (`insufficient_headroom` with free/needed/shortfall, nothing written, nothing closed), re-checked before every save, with `headroomBytesBefore` and `estimatedWriteBytes` always reported — because a recovery file can be the RIGHT document CUT SHORT and no check on the file can tell (the marker is at offset 4, so every prefix carries it); the recovery file is verified STRUCTURALLY — it must be a recognised SOLIDWORKS container AND at least as big as the smallest real document of its KIND (part 24 KB, assembly 80 KB, drawing 256 KB — one floor for all three would have to be the lowest of them, letting an assembly stop at a third of its size), with the KIND carried from the COM read to the verdict and NEVER re-derived from the recovery file's name, because that name is chosen from the kind and so cannot check it; an unreadable document type falls back to the document's OWN PATH and then its title (evidence about the document, not names this sweep chose) and only then to UNKNOWN, which takes a neutral `.sldrecover` name and the strictest floor — and if SOLIDWORKS refuses to write that name the file is re-saved as `.SLDPRT` with the fallback RECORDED and the unknown-kind verification unchanged; `swx_recover_list` re-runs the verdict on every file it lists and reports `verified` with the reason — a file the sweep refused is listed (never deleted: those bytes are the only ones there are) but never called a rescue, never offered as openable, and its entry says the document was left OPEN — and gives every genuine rescue its own `howToOpen`, because SOLIDWORKS will not open a `.sldrecover` file until it is renamed, published as `recoverySignatureOk` + `recoveryBytes`, and it fails CLOSED (`exists()` passes a zero-byte file and any byte floor passes a truncated one; both are what a full disk leaves). The container marker was MEASURED on 166 real documents from three hardware repositories, because the signature every reference names for a SOLIDWORKS document (OLE compound file) appears in NONE of them — verifying that, as pass 23 specified, would have refused to close every document on a modern install; **FIRST AUTONOMOUS CLOSE, live 2026-09-17**: `swx_close_all_safely {userAuthorized, batch:1}` on a bridge-launched session took 4 documents (3 same-titled pathless import temps + a throwaway part) to `recovery\20260917-150646\` under unique names (25,834 / 26,031 / 25,773 / 26,047 bytes, `recoverySignatureOk:true`, `namingFallback:null`), closed each BY PATH, count 4 → 0, `done:true`, `stoppedBy:null`, headroom 1.59 TB — nobody was asked to close anything. A document that could not be written is left OPEN and named in `remaining`; the user's saved-then-edited file is copied to recovery under a new name and their original is asserted byte-for-byte unchanged; `SaveAs3` uses `swSaveAsOptions_Silent` ALONE so the document RE-POINTS at the recovery file and the close key is read from the document after the save; a dialog stops the sweep and is never answered, with the census taken before EVERY save and EVERY close (a save can raise a prompt of its own, which a per-batch census could not see until the closes had happened); and an UNREADABLE window census is its own outcome — saves proceed, nothing closes, `stoppedBy:"dialogCensusUnknown"`. Ordering is proven from a journal, not from the end state. Every rule is mutation-verified (disable it and exactly one test fails). **Ownership measured live 2026-09-30, both halves**: a pathless `Part2` preserved (25,959 bytes, verified, listed from the manifest) and Drew's saved `R_0603_1608Metric_1.step-1.sldprt` closed with its original untouched (same 32,311 bytes, same mtime). Since 0.9.0 such a document is RECORDED as `closedInPlace` with its original's size and mtime, and `swx_recover_list` says "closed at its own path, untouched" — or that it has changed since, or is gone — by measuring the file, not by assuming it | ✗ | ✗ | ✗ | ✗ | ✗ |  *(49 numbered rows; rows 29–31 are the N/A block that accounts for most of the Fusion verb-count gap. Rows 36–42 added 2026-09-08 from the crate build and the launch-resilience rounds; rows 43–45 from the import-leak, quit and safe-close work, and 46–49 from the live runs of 2026-09-17 and 2026-09-30 — the bar is "significantly more powerful than the MCP servers", so every robustness capability gets a row with its evidence level, not a slogan.)* 
docs/LAUNCH-RESILIENCE.md+32
@@ -948,6 +948,38 @@ 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. +**And one level up — SEV-1, conditional, moved again in 0.9.1 (adversary pass+39).** 0.9.0's `Adom Bridge\solidworks-data\` was out of the directory ab+re-extracts on every BRIDGE update, and INTO the directory ab's own INSTALLER+owns. The facts, kept apart by how much they are worth:++| | Source |+|---|---|+| The bridge cache `Adom Bridge\bridges-cache\solidworks\` is re-extracted by a bridge update | **measured** 2026-09-30 (created 2026-09-18 09:42 by the 0.8.9 auto-install; the 09-17 recovery folders gone) |+| ab "installs to `%LOCALAPPDATA%\Adom Bridge\`" | ab's docs (`adom-bridge-installer`) — a claim |+| "The uninstaller lives at `%LOCALAPPDATA%\Adom Bridge\uninstall.exe`" | ab's docs — a claim |+| "ab removes its own `bridges-cache` copy of you" | ab's docs (`adom-bridge-sdk-manifest`) — a claim |+| What an ab uninstall, a repair, or "delete the Adom Bridge folder and reinstall" does to anything ELSE in `Adom Bridge\` | **documented nowhere, measured never** |++The recovery folder is the only copy of unsaved work, and its survival cannot+hang on the last row — that is the shape of the measured SEV-1, one level up.+Measuring one ab uninstall would settle it for one ab version; it would not+settle it for the next. So the data root moved to a SIBLING of `Adom Bridge\`+that nothing but this bridge writes, and whose only deleter is a person:+`%LOCALAPPDATA%\adom-solidworks-bridge\` (`%USERPROFILE%\adom-solidworks-bridge\`+when LOCALAPPDATA is missing; and if neither exists, the temp-directory fallback+makes every sweep refuse with `data_root_is_temp`). Both earlier locations are+now LEGACY ROOTS, migrated oldest first under the same rules, plus one the+adversary found missing (sev-2): a legacy file is recorded by (root, path, size,+mtime, sha256 of the copy), and one that CHANGES after its copy — a rollback+appending to the old ledger, rewriting the old ownership registry — is copied+again beside the first copy (`<name>.legacy-<stamp>`), never over it, and+reported `changedSinceCopy` in the record, the ledger and `swx_recover_list`. A+sweep refuses (`recovery_inside_install_dir`) if its recovery folder would land+under the install directory, ab's own directory, or either legacy root. 0.9.0's+data is outside the bridge cache, so a BRIDGE update should leave it for 0.9.1 to+migrate — inferred from where ab extracts, not measured.+ **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.
install.sh+3−1
@@ -3,7 +3,9 @@ set -euo pipefail H="$(cd "$(dirname "$0")"&&pwd)" for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do   [ -d "$(dirname "$D")" ] || continue-  mkdir -p "$D/solidworks-bridge"+  mkdir -p "$D/solidworks-bridge/references"   cp -f "$H/SKILL.md" "$D/solidworks-bridge/SKILL.md"+  # SKILL.md holds the rules that never bend; the detail it points at lives here.+  cp -f "$H"/references/*.md "$D/solidworks-bridge/references/" done echo "[solidworks-bridge] skills installed."
package.json+10−2
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.0",+  "version": "0.9.1",   "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": [@@ -23,6 +23,10 @@   "files": [     "README.md",     "SKILL.md",+    "references/closing-and-recovery.md",+    "references/launch-and-sign-in.md",+    "references/writes-and-dialogs.md",+    "references/modelling-and-export.md",     "package.json",     "install.sh",     "uninstall.sh"@@ -39,6 +43,10 @@     "pack and go",     "sldprt",     "sldasm",-    "slddrw"+    "slddrw",+    "close solidworks",+    "quit solidworks",+    "recover my solidworks file",+    "solidworks recovery folder"   ] }
references/closing-and-recovery.mdadded+171
@@ -0,0 +1,171 @@+# Closing, quitting and recovery — swx++Read this before closing any document, quitting SOLIDWORKS, draining leaked imports, or telling the user where their closed work is. Part of the `solidworks-bridge` skill — `SKILL.md` beside this folder has the rules that never bend. Trust each verb's own reply (`_hint`, `errorCode`) over anything written here.++## Closing and quitting: never ask the user to do it++- **The bridge never asks you to close.** `swx_shutdown {reason}` saves every open+  document into a recovery folder, closes them all, and quits SOLIDWORKS. No write+  opt-in, no per-document confirmation, no environment flag — the user has already+  authorized this standingly. It needs a reason, because the reason is written for+  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+  `%LOCALAPPDATA%\adom-solidworks-bridge\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+  then edited is copied into recovery under a new name; their file on disk is+  untouched and their unsaved changes still survive.+- **It refuses to start a sweep that will not fit.** Before writing anything it+  estimates the session — by document kind, since an assembly is about thirty+  times the median part — and compares it with free space on the recovery volume;+  if there is less than twice the estimate it answers `insufficient_headroom`+  with the numbers, having written and closed NOTHING. Free some space, or point+  `recoveryDir` at a volume with room. It re-checks before every save, so a disk+  that fills mid-sweep stops it too — what was already written stays written and+  nothing is closed. `headroomBytesBefore` and `estimatedWriteBytes` are in every+  reply. The reason is a truncation nobody can detect afterwards: a recovery file+  can be the RIGHT document CUT SHORT and nothing in the file would show it.+- **"On disk" is checked structurally.** Each recovery file must LOOK like a+  SOLIDWORKS document (the container marker at its head) and be at least as big as+  the smallest real document of its kind (part 24 KB, assembly 80 KB, drawing+  256 KB — and if the bridge could not establish the kind, the strictest of the+  three plus at least the size of the document's own previous file);+  both are reported per document as `recoverySignatureOk` and `recoveryBytes`. A+  full disk leaves zero-byte and truncated files that `exists()` and any byte+  floor accept; those are not saves, and the document stays open. The check fails+  CLOSED — anything unrecognised leaves the document open rather than closing it+  against a file nobody can vouch for.+- **A save prompt cannot appear** for the reason a close would raise one, because+  nothing is unsaved by the time a close happens. But a SAVE can raise one of its+  own (a missing external reference, a rebuild question), so the window census+  runs before EVERY save and before EVERY close. If ANY dialog appears the sweep+  stops where it is, reports it in `stoppedByDialog`, and does nothing further.+  It is never answered. Everything already written stays written.+- **If the window census cannot be READ, nothing closes.** Unknown is not "no+  dialog". The saves still run — saving loses nothing — but no document is closed+  and the reply carries `stoppedBy:"dialogCensusUnknown"`,+  `dialogCensusReadable:false`, `done:false`. The work is already safe in the+  recovery folder; tell the user that, and run it again when `swx_status` shows+  the session is readable.+- **`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. **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.+- **Older locations are listed too, and say what can take them.** Before 0.9.1+  the data lived inside ab's directories: 0.8.x in the bridge cache, which every+  bridge update re-extracts (`location:"legacyInstallCache"`,+  `atRiskFromUpdate:true`), and 0.9.0 in `Adom Bridge\solidworks-data`, which+  ab's installer and uninstaller own (`location:"legacyDataRoot"`). Both carry+  `atRisk:true`. If a sweep's `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 or reinstalls. In the data root, a file marked `notCopiedYet` exists+  only at its `legacyPath` (same warning); one marked `partialCopy` is an+  interrupted copy — incomplete, never a rescue — and the file it was copying+  says `partialCopyOnly`. A file `copiedBeside` (`<name>.legacy-<stamp>`) is a+  DIFFERENT version of `besideOf` that an older location held after the first+  copy; `migration.changedSinceCopy` lists every such change. Check both before+  relying on either.+- **Two refusals that wrote and closed NOTHING, and are not disk problems:**+  `recovery_inside_install_dir` (the data root was pointed inside the install+  directory, ab's own directory, or an older bridge's location) and+  `data_root_is_temp` (neither LOCALAPPDATA nor USERPROFILE was set, so the data+  root fell back to the OS temp directory, which Windows empties on a schedule).+  Say what the reply says; do not retry until the root is fixed.+- **`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.+- **What did NOT change.** `swx_stop` still quits only a session with zero+  documents open; the ladder still refuses on a dialog, still measures the exit+  from the process census, and still never kills anything. The authorization moved+  the consent question, not the data-safety guarantees.++## Quitting, draining and flooded sessions++- **`swx_close_all_documents` accounts for everything it leaves open.** Its+  `skipped` array names every document still open when it returns, with why —+  and a document whose dirtiness SOLIDWORKS will not report is treated as+  UNSAVED, not clean. Reaching this verb at all needs the write opt-in, since+  it ends the user's session; a refusal closes nothing.+- **`swx_stop` quits ONLY an empty session, and proves the exit from the process+  census.** It is a measured ladder, pinned at rung (a) (`UserControl=false;+  ExitApp; release every COM reference`); rungs (b)-(f) exist and need an+  explicit `maxRung`, and none of them has ever run against a real SOLIDWORKS.+  Before it tries anything it re-measures seven preconditions, and **any one of+  them coming back UNKNOWN refuses** (`stop_preconditions_unknown`) — an+  unanswerable question is not permission. The commonest refusal is+  `open_documents_present`: **documents open means no**, and `force` does not+  waive it (`force` waives the UNSAVED question only). Success is *this pid with+  this creation time absent from the census* — never what `ExitApp` returned —+  and nothing the bridge owns is disowned until that is true, so a declined quit+  leaves you still able to save and close your own documents. If a dialog+  appears the ladder stops permanently and reports it+  (`stop_blocked_by_save_prompt` / `stop_blocked_by_dialog`): **this bridge never+  answers a dialog** — show the user `swx_state`'s background screenshot and let+  them answer. A decline is `success:false` with+  `errorCode:"host_app_stop_declined"` and the whole `rungsTried` list. **Do not+  reach for `swx_kill`**: it discards exactly the work the dialog is asking+  about, and on a Connected edition relaunching takes 1-3 minutes and can fail.+  `expectedSurvivorsAlive` (the 3DEXPERIENCE tray daemons, `ENOPLMCSAClient`,+  `EdmServerV6`) is CONSENT, not a leak — never end one. Every precondition is+  re-measured before EVERY rung, so a multi-rung ladder can refuse in the middle+  (`refusedMidLadder:true`) if a document appears while a rung is settling, and it+  never continues against a different process (`stop_identity_changed`).+- **On a flooded session, every list is a SAMPLE and says so.** Above the+  enumeration cap `swx_docs` returns `{count, truncated:true, sample,+  importTempsInSample, importTempsInSampleOf}` and NO `docs` list — it walks ten+  documents, it does not enumerate thousands (doing that killed the com host on a+  live 2144-document session). `swx_close_all_documents` on such a session still+  closes, but reports `closed`/`remaining` as **null** with counts, and names the+  unknown in `skipped`. Never read a null list as "nothing there".+- **`close_outcome_unknown` means a HUMAN has to look.** It is the drain's third+  outcome: a close reported success, the document is still open, and the count could+  not be read — so it cannot be told whether nothing closed or something else did.+  The drain stops. Do not retry it; ask the user what is on their screen, and read+  `closeFailed` for the path and the count immediately before the close.+- **A drain that says `done:false` is not finished — read `walkStop`.** The drain+  looks at a bounded WINDOW per call (`documentsScanned`), and `done:true` means the+  walk reached the END of the session with nothing left. `walkStop` is+  `reachedEnd` / `hitMax` / `timedOut` / `iterationFailed`, and+  `titleCollisionsCheckedOver` says how many documents the collision check actually+  covered — a same-titled document beyond the window is not in that list. Call it+  again until `done:true`.+- **The drain never closes a document whose title another open one shares** —+  compared the way `CloseDoc` resolves a name, which on Windows is+  case-insensitively. If the open set cannot be enumerated it does not attempt the+  registry sweep at all (`ownedUnsavedSweepBlind:true`, `done:false`): it cannot+  tell whether a title is shared, and a title-only document can only be closed by+  name. It+  closes by full path and verifies each close, but `CloseDoc` resolves by name — so+  a shared title is refused and reported in `titleCollisions` (with every path that+  shares it) rather than letting SOLIDWORKS pick. The user decides: close it+  themselves, or save the other document under another name. `done:false` while any+  collision stands.+- **`swx_close_import_temps` needs a resolved `%TEMP%`, and says so.** The drain+  identifies a temp by its LOCATION under this user's resolved temp directory —+  that path is the whole proof, because the close does not prompt. A path that+  merely looks like a temp (a restored profile backup, another user's profile) is+  not enough, and when `%TEMP%` cannot be resolved the verb refuses with+  `temp_root_unknown` rather than reporting that it found nothing to close. Read+  `tempRoot` and `importTempsElsewhere` in the reply: `closed:0` with+  `importTempsElsewhere:N` means N documents look like leaked temporaries but sit+  under a directory this bridge could not resolve (ab can run under a different+  profile or a redirected TEMP), so the drain is NOT done — `done:false` — and the+  hint says how to point it at the right root.
references/launch-and-sign-in.mdadded+173
@@ -0,0 +1,173 @@+# Launch, sign-in and readiness — swx++Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPERIENCE sign-in, or when deciding whether it is ready. Part of the `solidworks-bridge` skill — `SKILL.md` beside this folder has the rules that never bend. Trust each verb's own reply (`_hint`, `errorCode`) over anything written here.++## Install, launch, sign-in++- **This edition cannot be launched by COM, so do not try.** The install here is+  SOLIDWORKS Connected / Makers (3DEXPERIENCE), and `swx_status` reports+  `canLaunchProgrammatically:false`. COM can ATTACH to a session that is already+  running; it cannot create one. That is a property of the edition, not a+  limitation of the bridge, so there is no argument or retry that changes it.+- **The launch that works is the Platform's own shortcut.** `swx_start` invokes+  the 3DEXPERIENCE Platform `.lnk`, which starts `CATSTART.exe`, which starts+  `SWXDesktopLauncher.exe`, which starts `sldworks.exe`. The launcher chain then+  exits — once `sldworks.exe` is up there is no launcher process left to poll or+  to reap.+- **The `shellExecute` route hands back a real handle, proven live 2026-09-11.**+  It invokes through `ShellExecuteExW` with `SEE_MASK_NOCLOSEPROCESS`; the live+  run got a process handle back (CATSTART pid 131272, creation time 1789148101)+  and that chain reached the launcher's `Run Begin`. This matters because the+  bridge's law is **no handle, no kill**: it may only clean up a chain the invoke+  itself handed back. On this machine that world is confirmed.+- **`stage:"helperSignIn"` means a human has to type a password — you cannot.**+  A cold launch can stop at the 3DEXPERIENCE ID window: a NATIVE WebView2 window,+  class `DSYSysLoginPanelWebView2Class`, title+  "Login | 3DEXPERIENCE ID | Dassault Systemes", owned by `SWXDesktopLauncher.exe`+  in the bridge's own chain. UIA sees NO controls inside it, so nothing can read+  or fill it — this bridge included. As of 0.6.0 the bridge recognises the window,+  reports `helperSignIn`, PAUSES every launch deadline while it is on screen (a+  waiting human is not a stall), never kills or escalates the chain, and toasts+  the user that sign-in is needed. Your move is to keep polling and, if the user+  asks what to do, tell them to sign in and to tick **Remember me** once so later+  launches do not stop here. The bridge never asks for, stores or transmits a+  password.+- **How long `helperSignIn` lasts depends on the user answering the window**, not+  on any timer the bridge owns. Do not convert it into a failure.+- **Read `launch.stage` and act on it**, from `swx_status` or from `swx_start`:++  | `stage` | What to do |+  |---|---|+  | `invoked` | The route was just invoked. Poll `swx_status`. |+  | `launcherStarted` | Chain running, `Run Begin` not reached. This is the stage that stalls; the bridge cleans up and tries the next route itself. Do not invoke the shortcut any other way. |+  | `runBegin` | The chain is really building. Keep polling; `sldworks.exe` appears within seconds. |+  | `helperSignIn` | Only the user can clear it. Poll; tell them to look at their screen. |+  | `sldworksUp` | The application exists, COM does not answer yet. Keep polling. |+  | `attaching` | Polling for the COM server. Keep polling; do not launch again. |+  | `attached` | COM is usable. This is the only success — carry on. |+  | `stalled` | That route stalled and its chain was cleaned up. The bridge is trying the next route. Nothing for you to retry. |+  | `exhausted` | Every route was tried and none attached. ASK THE USER to start SOLIDWORKS from the Start Menu or the Platform. |++  The three milestones that matter operationally are **`runBegin`** (the chain is+  really building), **`sldworksUp`** (the application exists) and **`attached`**+  (COM is usable, and the only success).+- **`stillLaunching:true` means a launch this bridge started is still in flight.**+  Calling `swx_start` again does NOT start a second chain — it attaches to the one+  in flight. That is deliberate: four launcher chains once ended up on a user's+  desktop because a caller retried. Polling with `swx_start` is therefore safe,+  and a second invocation by any other route is not.+- **`swx_stop` quits ONLY a session with zero documents open, and `force` does not+  waive that** (`force` waives the UNSAVED question only). If documents are open,+  close them first, or drain leaked conversion temporaries with+  `swx_close_import_temps`.+- **Treat a quit as close to a one-way door.** On this edition relaunching after a+  quit takes 1-3 minutes and can stop at the sign-in window again, which only the+  user can answer. Do not quit to tidy up.++## Is it ready?++- `bridge_readiness` (ab-level, never spawns anything) answers "is SOLIDWORKS+  installed / is the bridge warm" — use it for what-do-I-have questions.+- `swx_readiness` is the bridge's own read-only probe; `swx_status` is the+  everyday state check (detection + COM attach + open docs);+  `swx_get_app_state` is the one to POLL while something long runs.+- **If SOLIDWORKS is not installed** verbs return+  `errorCode:"host_app_not_installed"`. Dassault publishes NO silent+  installer — desktop trial serials come from a reseller, the Makers edition+  from the 3DEXPERIENCE Platform store. Don't tell the user to go download it;+  surface the returned `_hint`.+- **If SOLIDWORKS is not running, `swx_start` starts it — including the+  Connected editions.** Pass a `reason` (it is written for the person whose+  desktop this is, and it is the only thing this verb needs — starting an+  application creates nothing and destroys nothing).+  - On classic desktop installs it attaches over COM.+  - On **3DEXPERIENCE / Connected** editions (including *Design Professional+    for Makers*) it invokes **the shortcut the Platform installed**, which runs+    `CATSTART.exe -run SWXDesktopLauncher.exe` with that user's tenant and+    registry. That is the launch Dassault sanctions.+  - **A cold Connected start runs 1-3 minutes** and may stop at a 3DEXPERIENCE+    sign-in only the user can answer. `swx_start` returns+    `started:true, stillLaunching:true` while it is coming up — that is **not a+    failure**. Poll `swx_status` until `running:true`, or call `swx_start`+    again, which ATTACHES to what is already coming up. `waitSeconds:<n>` (up+    to 240) blocks instead of polling. **Never invoke the launcher any other+    way meanwhile** — two launches means two sign-in windows on somebody's+    desktop.+  - **Calling `swx_start` during a launch launches NOTHING** (v0.4.0). It+    answers `stillLaunching:true, launchedNow:false` with the live `stage`+    (`launcherStarted` / `runBegin` / `helperSignIn` / `sldworksUp` /+    `attaching`), its age, and what to do — and it needs no `reason` while an+    attempt is in flight, so polling with it is always safe. `swx_status`+    carries the same `launch` block.+  - **A stalled launcher recovers itself.** The launcher chain sometimes starts+    and then stops at `SecurityContext:` without ever logging `Run Begin` — it+    then sits idle forever and SOLIDWORKS never appears. A supervisor inside+    the bridge watches the launcher's OWN log, and on a stall it ends **only+    the `CATSTART`/`SWXDesktopLauncher` chain it started itself** and tries the+    next launch route, remembering whichever one works. **There is nothing for+    you to retry, and nothing for you to clean up.**+  - **NO HANDLE, NO KILL — and the same rule for evidence.** A launcher log is+    read as this bridge's own only when the launcher's own writing-thread id+    resolves to a process in our chain, or it is the sole candidate with no+    other launch running; otherwise `unattributedLog:true` and it is not used.+    On a route that cannot say what it started, no log is attributed at all. One consequence worth knowing:+    if SOLIDWORKS comes up on `explorer` or `cmdStart`, the launch SUCCEEDS and+    the route is never remembered — `attached` proves SOLIDWORKS is usable, not+    that our route did it.+  - **NO HANDLE, NO KILL.** A process is only ever ended when the INVOKE ITSELF+    handed back its root (`ShellExecuteExW`'s process handle, or+    `Start-Process -PassThru`'s pid) and it descends from that root. The+    `explorer` and `cmdStart` routes cannot say what they started, so a stall on+    either reports `cleanup:"notAttributable"` and **terminates nothing** — the+    response says a launcher chain may remain that the bridge cannot identify,+    which the user can end from Task Manager. If a route leaves something the+    bridge cannot account for, the machine STOPS rather than starting another+    chain beside it.+  - **If SOLIDWORKS is running but unreachable, `swx_start` will not start a+    second one.** It answers `host_app_hung` (Windows says the window is not+    responding AND a COM call exceeded its budget — only the user can end it),+    `host_app_exhausted` (**`NewDocument` came back with no document** — the one+    signal that proves it; close the bridge's own documents, then ASK THE USER+    to restart, and do not retry in a loop), or `host_app_com_unreachable`+    (usually a sign-in window: ask the user to look at their screen).+    `swx_status.solidworks.memoryAdvisory` reports a very large session as an+    OBSERVATION — a 13 GB SOLIDWORKS can be perfectly healthy, and this bridge+    no longer calls one out of room on a threshold.+  - **"Not responding" is not "dead".** `swx_status.solidworks.verdict` is+    `busy` / `dead` / `unknown`, from the CPU and memory DELTA across two reads+    (`cpuActiveSeconds`, `memoryDeltaMB`). A session at 17.9 GB was+    not-responding with a COM timeout for five minutes while burning 70% of a+    core — and it recovered. `busy` means **tell the user to wait**; only `dead`+    (no CPU over ≥15s, memory flat, no dialog) means only they can end it. One+    reading never says dead — poll again.+  - **After a failed attempt the bridge waits** (`host_app_launch_backoff`,+    growing with each consecutive failure) and then tries ONE route rather than+    re-sweeping the plan. Retrying in a loop is how four launcher chains ended+    up on somebody's desktop; the honest move while it waits is to ASK THE USER+    to start SOLIDWORKS from their Start Menu.+  - **Crash-reporter recovery ships OFF** (`launch.crashRecovery:"off"`). When+    armed it clicks the crash reporter's "Restart SOLIDWORKS Design" button+    once — but only on a window OWNED by the SOLIDWORKS being launched, and only+    when ab structurally confirms the button exists. It is disabled by default+    because the reply shape it reads has never been seen on a real desktop.++- **NEVER kill the 3DEXPERIENCE launcher daemons.** `3DEXPERIENCELauncher.exe`,+  `3DEXPERIENCELauncherSysTray.exe` and `3DEXPERIENCELauncherService.exe` are+  the Platform's own background services and carry the user's Platform session.+  They are **not required** for a launch (a successful launch on 2026-09-04 ran+  with no Backbone at all), so killing one buys nothing and breaks something+  that is not yours — and **a real user launch rebuilds them**, which is the fix+  if they are genuinely broken. The bridge's own kill allowlist is exactly two+  images, `CATSTART.exe` and `SWXDesktopLauncher.exe`, and only instances it+  created itself, terminated by pid. Do not reach for `taskkill` /+  `process_kill` against any of this yourself; `sldworks.exe` included (that is+  `swx_kill`, and it is gated behind the user's explicit opt-in).+  - `errorCode:"host_app_launch_blocked"` now means only one thing: **the+    bridge could not FIND a shortcut**, not that the edition cannot be+    launched. `swx_status` reports `solidworks.shortcut` and+    `solidworks.launchRoute` so you can see what it found; if the user has a+    shortcut elsewhere, `SWX_SOLIDWORKS_SHORTCUT=<path to the .lnk>` points the+    bridge at it. Do not run `sldworks.exe` yourself via `shell_execute` or+    `launch_process` — launching it DIRECTLY, bypassing the launcher chain, is+    what produces the *"SOLIDWORKS Design must be launched from the+    3DEXPERIENCE Platform"* modal.
references/modelling-and-export.mdadded+115
@@ -0,0 +1,115 @@+# Modelling, importing and exporting — swx++Read this when creating geometry, importing a neutral file, exporting, or mapping a Fusion 360 habit onto SOLIDWORKS. Part of the `solidworks-bridge` skill — `SKILL.md` beside this folder has the rules that never bend. Trust each verb's own reply (`_hint`, `errorCode`) over anything written here.++## 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.++## Making something new++```bash+adom-bridge --ai-thread "<thread>" swx_new_document '{"type":"part"}'+adom-bridge --ai-thread "<thread>" swx_run_script '{"script":[+  {"op":"sketch_rect","plane":"Front Plane","widthMm":50,"heightMm":30},+  {"op":"extrude","depthMm":20},+  {"op":"set_material","name":"6061 Alloy"},+  {"op":"set_prop","name":"PartNo","value":"BRK-100"}+]}'+adom-bridge --ai-thread "<thread>" swx_inspect_bodies '{}'      # verify the GEOMETRY+adom-bridge --ai-thread "<thread>" swx_save_as '{"outputPath":"C:\\parts\\bracket.sldprt"}'+```++- `swx_new_document` gives you an **unsaved** document: it has a title (`Part1`)+  and **no path** until `swx_save_as`. Target it by `name`, not by path.+- `swx_run_script` takes a JSON **array of steps**, not a string of code —+  SOLIDWORKS has no in-process scripting host the way Fusion has Python. Every+  dimension is in **millimetres**. A rejected script returns the full op list+  and a worked example, so read the error.+- **Verify with `swx_inspect_bodies`, not with the step list.** It reports each+  solid's bounding box, size, volume, area and mass in millimetres — that is+  what tells you the model is what you meant.+- Throwing it away is `swx_close_doc {"name":"Part1","save":false}`, or+  `swx_reset_workspace` to sweep every scratch document the bridge created.+- **`swx_import` opens one unsaved document and it STAYS OPEN** until you close+  or save it — exactly like `swx_new_document`. It reports `documentsOpened:1`+  and `netCountOk:true`. Importing in a loop without closing is what leaks+  documents (a live session was found holding 1342 STEP temps, 8 GB). If a count+  ever looks huge, `swx_docs` will say `truncated:true` with a `count` and a+  small `sample` instead of timing out — run **`swx_close_import_temps`**+  `{"reason":"…"}` to drain the leaked `…\Temp\swx<pid>\IC~~\…` conversion temps.+  That verb works in the default posture and can never touch a saved user+  document (a user file has a real path; a temp does not).++## The everyday flow++1. `swx_find_files {"roots":["C:\\designs"],"query":"gearbox"}` — locate+   documents on disk (works with SOLIDWORKS closed; newest first).+2. `swx_open {"path":"...sldasm"}` — silent open, background. SOLIDWORKS+   launches automatically if closed (cold start 30-90s; the verb's `progress`+   block and `swx_progress` report it).+3. Work the document:+   - `swx_bom {"format":"csv"}` — quantities per (file, configuration) with+     every custom property as a column; `csvPath` lands on the DESKTOP disk.+   - `swx_mass_properties {}` — mass/volume/area/CoM in SI units.+   - `swx_interference {}` — assembly clash check (`count:0` = clean).+   - `swx_rebuild_check {}` — force rebuild + broken-feature census.+   - `swx_props_get` / `swx_props_set` — custom properties (then `swx_save`).+   - `swx_export_step {"outputPath":"C:\\out\\part.step"}` — or+     `_iges/_stl/_3mf/_glb/_sat/_parasolid/_pdf/_dxf/_dwg`, or the generic+     `swx_export {"format":"..."}`. **Verified live from a part:** STEP, IGES,+     STL, X_T, X_B, SAT, 3MF, GLB, PLY, AMF, 3DXML, PNG, JPG, TIF, PDF. DXF,+     DWG, OBJ and U3D are **refused for a part** (DXF/DWG are drawing formats).+     VRML is blocked by the bridge — it hangs SOLIDWORKS on a modal.+   - `swx_design_info {}` / `swx_inspect_bodies {}` / `swx_get_parameters {}` /+     `swx_set_parameter {"name":"D1@Boss-Extrude1","valueMm":25}` — read the+     design back and drive it.+   - `swx_pack_and_go {"out":"C:\\handoff"}` — self-contained copy with all+     references, for sending a design anywhere.+4. `swx_state {}` any time — open docs + active doc, the look-then-act check.++## Export and cloud++- **The export format is the DESTINATION EXTENSION, not the `format` argument.**+  `swx_export_step {"outputPath":"x.wrl"}` writes VRML, not STEP — which is why+  the bridge refuses `.wrl` on the filename (`errorCode:"format_blocked"`) on+  every route. VRML raises a modal that hangs SOLIDWORKS for minutes.+- **There is no cloud.** `swx_cloud_status` explains why with a live probe: the+  SOLIDWORKS COM API exposes no 3DSpace/3DDrive member at all. Local files work+  fully. Do not go looking for a cloud-open verb.++## Coming from the Fusion 360 bridge?++Arg names and response keys mirror `fusion_*` wherever the concept survives:+`outputPath`, `filePath`, `name`, `parameters`, `bodies`, `current`, `applied`,+`actions`, `stages`, `wouldPrompt`, `closed`, `remaining`. The four things that+do **not** transfer:++| Fusion | SOLIDWORKS |+|---|---|+| `fusion_start` launches the app | `swx_start` launches Connected editions through the Platform's own shortcut — 1-3 minutes cold, and a sign-in window only the user can answer may stop it |+| `fusion_run_modeling_script` takes Python | `swx_run_script` takes a JSON array of typed steps |+| Fusion has no macro road | `swx_run_macro` runs a VBA `.swp`/`.swb`/`.dll` — always gated, and not yet live-proven |+| 21 cloud verbs (APS) | none — `swx_cloud_status` says why |+| `fusion_export_optimized_glb` needs a service | `swx_export_glb` is native |++`docs/FUSION-PARITY-REPORT.md` in the bridge repo has all 132 Fusion verbs with+a verdict.
references/writes-and-dialogs.mdadded+153
@@ -0,0 +1,153 @@+# Writes, dialogs and targeting — swx++Read this before anything that writes — saving, exporting over a file, setting properties or parameters, running a script or macro, closing someone's document — and before touching any dialog or window. Part of the `solidworks-bridge` skill — `SKILL.md` beside this folder has the rules that never bend. Trust each verb's own reply (`_hint`, `errorCode`) over anything written here.++## Rules and gotchas++- **Target docs by `path`**, not by activating them — every doc verb takes an+  optional `path` and falls back to the active document.+- **Files land on the DESKTOP filesystem.** From a cloud container, pull+  exports back with ab's `pull_file`.+- **Background etiquette:** the bridge never foregrounds SOLIDWORKS; responses+  carry `windowEtiquette:"background"`.+- **A modal dialog inside SOLIDWORKS blocks every verb** (COM is single+  threaded). If verbs time out, ask the user about a dialog, or check+  `swx_status`. The bridge holds the COM object in a separate child process, so+  a wedged call is killed and replaced rather than taking the bridge down — the+  verb after a timeout works again.+- **`swx_props_set` changes memory, not the file** — follow with `swx_save`.+- `swx_com_eval {"expr":"...","reason":"..."}` is the maintainer escape hatch:+  a late-bound COM call chain rooted at `$sw` (the application) or `$doc` (the+  active document), e.g. `$sw.RevisionNumber()` or+  `$doc.Extension.CustomPropertyManager("").GetNames()`. String, number and+  boolean literal arguments. For prototyping a capability against a real+  install before it becomes a verb; `reason` is required.+- `swx_screenshot` delegates to ab's generic `desktop_screenshot_window`;+  `swx_state {}` bundles that with the dialog census and this machine's+  measured verb timings in one call (`screenshot:false` to skip the callbacks).+- **Do not dismiss dialogs casually.** `swx_dismiss_blocking_dialogs {}`+  REPORTS by default. Closing needs `close:true` + `reason` + `confirm`; a+  "Save changes?" / Document Recovery / licence dialog is never closed at all;+  and a window that cannot be PROVED to belong to SOLIDWORKS is never closed+  either (ab's window list carries no owner pid, so a bare dialog class could be+  any application's). `swx_close_window` refuses the same way, with+  `errorCode:"window_not_attributed"`.+  Closing a stuck SOLIDWORKS modal through the window manager has been observed+  to take the whole application down — and on Connected editions the bridge+  cannot start it again.+- **☠ THE BIG ONE: writes are DENY-BY-DEFAULT.** The bridge refuses to change+  any document it did not create, and to write over any file that already+  exists. That includes things that do not look like writes: `swx_props_set`+  (it dirties the document, so the user's own Ctrl+S would commit it),+  `swx_rebuild_check` (it calls `ForceRebuild3`), `swx_bom {format:"csv"}` (it+  writes a file), `swx_pack_and_go`, and `swx_stop` /+  `swx_close_all_documents` / `swx_close_doc` (they end or disturb the user's+  session).+  Check `swx_status.userWrites` (`"disabled"` / `"enabled"`) before planning+  anything that writes. Refusals are `errorCode:"user_writes_disabled"` or+  `"destination_exists"`; the response lists `bridgeDocuments` (each with the+  `path` that names it unambiguously) so you can retarget. **Exporting to a path+  that does not exist yet, from any document, always works** — that is the+  normal way to get data out. To reach the user's own document or overwrite a+  file, the bridge process must have been started with+  `SWX_ALLOW_USER_WRITES=1` AND the call must carry `allowUserWrites:true` ++  `confirm:true` + `reason`. **You cannot grant this to yourself — ask the+  user.** Every exception is recorded in `swx_action_ledger`.+- **The affected verbs:** `swx_save`, `swx_save_as`, `swx_export*`,+  `swx_bom {format:"csv"}`, `swx_pack_and_go`, `swx_close_doc`,+  `swx_props_set`, `swx_set_parameter`, `swx_set_preference`,+  `swx_run_script`, `swx_run_macro` (**always** — see below),+  `swx_rebuild_check`, `swx_stop`, `swx_kill`,+  `swx_close_all_documents`, `swx_open {readOnly:false}`, and BOTH dialog-closing+  verbs — `swx_dismiss_blocking_dialogs {close:true}` and `swx_close_window` —+  for any dialog at all, including ones the bridge itself raised. **Reading is never gated**, and+  neither is exporting to a path that does not exist yet.+- **`swx_run_macro` is gated ALWAYS, with no safe case.** A macro is a VBA+  project with the whole SOLIDWORKS API in scope and the filesystem behind it:+  it can model, save over anything, batch-export, delete, or quit, to documents+  this bridge has never seen — and nothing about the *call* predicts which.+  So the two questions that bound every other write ("is the document ours",+  "is the destination clear") do not apply, and running one needs the full+  opt-in chain every time, even for a macro the bridge itself shipped. In the+  default posture it refuses. `module` and `procedure` default to `main`/`main`+  (the macro recorder's defaults); the macro is unloaded after the run, so an+  edited `.swp` runs its new code. **`swx_run_macro` has not yet been run+  against a live SOLIDWORKS** — its response says so (`provenLive:false`), and+  the bundled `resources/macros/adom-hello.swp` is a placeholder, not a working+  macro.+- **What the opt-in actually opens.** When the user HAS opted in, in full, the+  bridge does the thing — it does not refuse anyway. `swx_close_doc` and+  `swx_close_all_documents {closeUnsaved:true}` will close and discard the+  user's own unsaved documents on that chain, with each one named in+  `swx_action_ledger`. Both the server and the com host check the identical+  chain independently, so a partial chain is refused twice and a complete one+  is honoured by both.+- **A destination this bridge cannot CHECK is not free.** Every write is gated on+  whether something is already at the destination, and that question has three+  answers: occupied (needs the opt-in), free (a bridge-created document may be+  written there without one), and **unverifiable** — a path this process cannot+  stat, which refuses with `destination_unverifiable` and is NOT waivable by the+  opt-in. It is not a permission question. The bridge and SOLIDWORKS do not share a+  filesystem view, so a UNC path the bridge cannot see may hold a file SOLIDWORKS+  would overwrite. Check it yourself, or write somewhere the bridge can see.+- **`swx_close_window` is gated exactly like `swx_dismiss_blocking_dialogs`.**+  It refuses an application window (`errorCode:"window_is_application_frame"` —+  closing a SOLIDWORKS frame quits it, which is `swx_stop`'s job); it refuses a+  dialog that asks about the user's own work at any posture+  (`errorCode:"dialog_belongs_to_the_user"` — Save changes?, Document Recovery,+  licence); and closing anything else needs the process-level write opt-in.+  `swx_window_info` marks every window with `isFrame` and `closable`, and+  `closable` already folds in all three refusals — trust it.+- **`swx_screenshot` captures SOLIDWORKS' own window, by handle.** A free-text+  `titleContains` is ignored; pass `hwnd` only if `swx_window_info` attributes+  it to SOLIDWORKS. For anything else on the desktop, ab's own+  `desktop_screenshot_window` is the right tool and carries the user's approval+  prompt.+- **`swx_open` is READ-ONLY by default, and `readOnly:false` is a WRITE.** A+  read-write open takes a write lock on the user's file and converts-and-dirties+  an older-version document in memory, so it goes through the same gate as every+  other write. Reading is always free. `swx_close_doc {save:false}` on a DIRTY document the bridge+  did not create additionally needs `confirm:true` + `reason`+  (`errorCode:"unsaved_documents"`) — and if SOLIDWORKS will not say whether the+  document is dirty, it is treated as dirty.+  **There is no undo.** Do NOT reach for `swx_close_doc {save:false}` to "undo"+  a change to someone's document: that discards the entire document, not your+  edits. Undo belongs to the user, in SOLIDWORKS.+- **Destructive verbs ask first.** `swx_close_all_documents`, `swx_close_window`,+  `swx_kill` and `swx_close_doc`-on-dirty-work need `confirm:true` (and+  `swx_close_window`/`swx_kill` a `reason`). `swx_stop` and `swx_kill` refuse+  outright while documents are unsaved (`errorCode:"unsaved_documents"`) — pass+  `force:true` only if the user has said the changes are disposable.+  `swx_close_all_documents {closeUnsaved:true}` additionally needs a `reason`,+  and it discards ONLY the bridge's own scratch documents: the user's unsaved+  ones are left open and listed in `skipped`.+- **`swx_props_set` IS gated now.** The old justification — "in memory until a+  gated save" — ignored the persist route that cannot be gated: the user. It+  leaves their document dirty, so SOLIDWORKS prompts "Save changes?" on close+  and their own Ctrl+S writes bridge-authored data into their file.+- **Name the document with `targetDocument`, not by hoping.** Every mutating+  verb resolves its target ONCE — `targetDocument` (a title), else `path`, else+  the active document — and the bridge acts on exactly that. A `targetDocument`+  naming nothing open is an error (`document_not_open`), never a silent fall+  back to whatever the user has in front of them; naming two different documents+  two ways is `ambiguous_target`. Prefer `targetDocument` whenever it matters:+  SOLIDWORKS' active document follows window focus, and the user may click+  something else mid-run.+- **`swx_com_eval` evaluates READS ONLY, by allowlist.** Members beginning+  `Get`/`Is`/`Has`/`Can`/`Enum`, plus a short list of navigation properties+  (`ActiveDoc`, `Extension`, `SketchManager`, `FeatureManager`, `Frame`,+  `Name`, `Value`, `Mass`, …). Everything else — including members nobody has+  thought of — is refused with `errorCode:"eval_member_blocked"` and a reason.+  It is an allowlist rather than a banned list because a banned list has to+  anticipate every dangerous member of an API with thousands, and the first one+  missed `SendMsgToUser2` (a modal that wedges the host), `SetUserPreferenceToggle`+  and `ForceRebuild3`. If a genuine READ is refused, that is a bug worth filing.+- **`swx_save_as` RENAMES by default.** The open document becomes that file and+  takes its name — which is what you want for a scratch part, and emphatically+  not what you want on someone's `bracket.sldprt`. `copy:true` writes the file+  and leaves the document alone; that is what every `swx_export*` does.+- **`swx_reset_workspace` is the safe cleanup**: it closes only documents this+  bridge process created and has not saved. The list lives in memory, not on+  disk, because SOLIDWORKS names new documents `Part1`/`Assem1` and a+  remembered title would eventually match a document the USER made. A bridge+  restart therefore forgets and the sweep closes nothing.
src/lib.rs+1
@@ -40,6 +40,7 @@ pub mod owned; pub mod quit; pub mod disk; pub mod scratch;+pub mod sha256; pub mod script; pub mod server; pub mod verbs;
src/scratch.rs+473−193
@@ -1,40 +1,68 @@-//! The bridge's DATA — and why it no longer lives where the bridge is installed.+//! The bridge's DATA — and why it lives in a directory nothing else touches. //! //! 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.+//! state, verb timings, settings, and the scratch files it gives its own+//! documents. //! //! ```text-//! %LOCALAPPDATA%\Adom Bridge\solidworks-data\+//! %LOCALAPPDATA%\adom-solidworks-bridge\ //! ``` //!-//! **SEV-1, MEASURED 2026-09-30.** Until 0.9.0 that root was+//! A directory ONLY THIS BRIDGE writes to, whose only deleter is a person. That+//! property is the whole design, and it had to be learned twice:+//!+//! **1. SEV-1, MEASURED 2026-09-30 (fixed in 0.9.0).** Until 0.9.0 the 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.+//! INSTALLS the bridge into. ab's updater re-extracts that whole directory: on+//! Drew's machine it was found CREATED 2026-09-18 09:42 by the auto-install of+//! 0.8.9, and every recovery folder from 09-17, the ledger, the ownership+//! registry and the scratch documents were gone. Only test documents were in+//! them — but the recovery folder is by design the ONLY copy of work the user had+//! not saved.+//!+//! **2. SEV-1, CONDITIONAL (adversary pass 39; fixed in 0.9.1).** 0.9.0 moved the+//! root to `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`: out of the directory ab+//! re-extracts, and INTO the directory ab's own installer owns. What ab's docs+//! say, as CLAIMS: the installer "installs to %LOCALAPPDATA%\Adom Bridge\", the+//! uninstaller "lives at %LOCALAPPDATA%\Adom Bridge\uninstall.exe", and "ab+//! removes its own bridges-cache copy of you". What NOBODY has documented or+//! measured: what an ab uninstall, a repair, or the commonest troubleshooting+//! move there is — "delete the Adom Bridge folder and reinstall" — does to+//! anything else in that directory. The only copy of unsaved work cannot hang on+//! another product's undocumented behaviour; that is the shape of problem 1, one+//! level up. So the root is now a SIBLING of `Adom Bridge\`, named for this+//! bridge alone.+//!+//! Both earlier locations are LEGACY ROOTS (`legacy_roots`, oldest first), and on+//! start `migrate_legacy` copies what they hold into the root: under a temporary+//! name, verified byte for byte, renamed into place, never over an existing file,+//! the originals LEFT where they were, and each file RECORDED by (legacy root,+//! path, size, mtime, sha256 of the copy). A legacy file that CHANGES after its+//! copy — a rollback appending to the old ledger, rewriting the old ownership+//! registry — is copied AGAIN, beside the first copy (`<name>.legacy-<stamp>`),+//! never over it, and reported `changedSinceCopy`. `swx_recover_list` reads the+//! root and every legacy root. //!-//! 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.+//! `refuse_inside_install` stops a recovery folder ever being placed under the+//! directory the bridge runs from, under ab's own directory, or under a legacy+//! root. And when there is no user profile location at all, the root falls back+//! to the OS temp directory — which Storage Sense and Disk Cleanup empty BY+//! DESIGN — so every sweep then REFUSES (`data_root_is_temp`) rather than put the+//! only copy of unsaved work there, and the start-up log says so. //!-//! 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.+//! WHAT THE MIGRATION CANNOT DO: a bridge update re-extracts+//! `bridges-cache\solidworks` BEFORE the new bridge first runs, so whatever an+//! older bridge wrote THERE is most likely gone by the time the migration looks.+//! `Adom Bridge\solidworks-data` is outside the bridge cache, so a BRIDGE update+//! should leave 0.9.0's data for 0.9.1 to migrate (inferred from where ab+//! extracts, not measured). //!-//! `SWX_SCRATCH_DIR` overrides the root (the test harness points it at a temp-//! 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+//! Resolution order: `SWX_SCRATCH_DIR` (tests and deliberate overrides — honoured+//! as given), then `%LOCALAPPDATA%\adom-solidworks-bridge`, then+//! `%USERPROFILE%\adom-solidworks-bridge`, and only then the temp directory.+//! `SWX_LEGACY_DIR` (a path list) and `SWX_INSTALL_DIR` override the legacy roots+//! and the install directory for the same reason. `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.@@ -43,47 +71,93 @@ 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. 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() {-            return PathBuf::from(dir);+/// The data directory's own name, under `%LOCALAPPDATA%` (or `%USERPROFILE%`).+/// Named for this bridge alone, so that nothing else ever has a reason to write+/// into it or to clean it up.+pub const DATA_DIR_NAME: &str = "adom-solidworks-bridge";++/// Where the root came from. Only `TempFallback` is a refusal: an explicit+/// override is the operator's choice (the test harness points it at a temp+/// directory on purpose), and the two profile locations are the design.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum RootSource {+    Override,+    LocalAppData,+    UserProfile,+    TempFallback,+}++impl RootSource {+    pub fn name(self) -> &'static str {+        match self {+            RootSource::Override => "SWX_SCRATCH_DIR",+            RootSource::LocalAppData => "LOCALAPPDATA",+            RootSource::UserProfile => "USERPROFILE",+            RootSource::TempFallback => "tempFallback",         }     }-    if let Ok(local) = std::env::var("LOCALAPPDATA") {-        if !local.is_empty() {-            // A SIBLING of `bridges-cache`, not a child of it.-            return Path::new(&local).join("Adom Bridge").join("solidworks-data");-        }+}++fn env_path(key: &str) -> Option<String> {+    std::env::var(key).ok().filter(|v| !v.trim().is_empty())+}++/// The root, and how it was chosen. Never inside an install location — see the+/// module comment for what that cost, twice.+pub fn root_and_source() -> (PathBuf, RootSource) {+    if let Some(dir) = env_path("SWX_SCRATCH_DIR") {+        return (PathBuf::from(dir), RootSource::Override);+    }+    if let Some(local) = env_path("LOCALAPPDATA") {+        // A SIBLING of `Adom Bridge\`, never inside it.+        return (Path::new(&local).join(DATA_DIR_NAME), RootSource::LocalAppData);     }-    std::env::temp_dir().join("adom-bridge-swx-data")+    // A PROFILE DIRECTORY BEFORE A TEMP ONE. `%TEMP%` is emptied on a schedule by+    // design; the user's profile is not.+    if let Some(profile) = env_path("USERPROFILE") {+        return (Path::new(&profile).join(DATA_DIR_NAME), RootSource::UserProfile);+    }+    (std::env::temp_dir().join(DATA_DIR_NAME), RootSource::TempFallback) } -/// 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));-        }+/// Everything the bridge creates lives under here — and nothing else does,+/// which is what makes `swx_uninstall` safe to run blind.+pub fn root() -> PathBuf {+    root_and_source().0+}++/// Did the root fall all the way back to the OS temp directory? Then no sweep may+/// write recovery files into it: that is the one standard location cleaned on+/// purpose.+pub fn root_is_temp_fallback() -> bool {+    root_and_source().1 == RootSource::TempFallback+}++/// ab's OWN directory — its installer's, its uninstaller's, and the parent of its+/// bridge cache. Nothing of this bridge's may live under it.+pub fn ab_dir() -> Option<PathBuf> {+    env_path("LOCALAPPDATA").map(|l| Path::new(&l).join("Adom Bridge"))+}++/// WHERE OLDER BRIDGES KEPT THEIR DATA, OLDEST FIRST: 0.8.x's install cache+/// (`Adom Bridge\bridges-cache\solidworks`), then 0.9.0's+/// `Adom Bridge\solidworks-data`. Read for migration and by `swx_recover_list`;+/// never written. `SWX_LEGACY_DIR` replaces the list (a path list, in order).+pub fn legacy_roots() -> Vec<PathBuf> {+    if let Some(list) = env_path("SWX_LEGACY_DIR") {+        return std::env::split_paths(&list).filter(|p| !p.as_os_str().is_empty()).collect();     }-    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"));-        }+    match ab_dir() {+        Some(ab) => vec![ab.join("bridges-cache").join("solidworks"), ab.join("solidworks-data")],+        None => Vec::new(),     }-    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));-        }+    if let Some(dir) = env_path("SWX_INSTALL_DIR") {+        return Some(PathBuf::from(dir));     }     std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) }@@ -142,59 +216,217 @@ pub fn is_within(inner: &Path, outer: &Path) -> bool {     !o.is_empty() && (i == o || i.starts_with(&format!("{o}\\"))) } -/// REFUSE A PATH THAT AN UPDATE WOULD DELETE.+/// REFUSE A PATH WHOSE LIFECYCLE BELONGS TO SOMETHING ELSE. /// /// 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.+/// never be placed under the directory the bridge is installed in (re-extracted+/// on update), under ab's own directory (its installer and uninstaller own it,+/// and what they do to the rest of it is undocumented), or under a location an+/// older bridge used (both of which are one of those two). The root is outside+/// all of 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()-                ));-            }+    let mut places: Vec<(&'static str, PathBuf)> = Vec::new();+    if let Some(d) = install_dir() {+        places.push(("the bridge's install directory", d));+    }+    if let Some(ab) = ab_dir() {+        places.push(("ab's own directory", ab));+    }+    for l in legacy_roots() {+        places.push(("a location an older bridge kept its data in", l));+    }+    for (what, d) in places {+        // INSIDE OR EQUAL: a location is re-extracted — or uninstalled — just the+        // same as anything under it.+        if is_within(path, &d) {+            return Err(format!(+                "refusing to write {} under {what} ({}): what happens to that directory is \+                 decided by something other than this bridge. ab re-extracts its bridge cache \+                 on every update — measured 2026-09-30: the 2026-09-18 auto-install of 0.8.9 \+                 wiped every recovery folder written the day before — and what ab's uninstaller, \+                 a repair or a reinstall does to the rest of ab's directory is undocumented. A \+                 recovery file is the user's only copy of unsaved work, so it goes in a directory \+                 only this bridge writes to (by default %LOCALAPPDATA%\\{DATA_DIR_NAME}).",+                path.display(),+                d.display(),+            ));         }     }     Ok(()) } -/// COPY WHAT THE LEGACY LOCATION HOLDS INTO THE DATA ROOT.+/// A second-granular timestamp, `yyyymmdd-hhmmss` (UTC), for names that must sort+/// and must not collide with an earlier copy.+pub fn stamp_now() -> String {+    let now = SystemTime::now()+        .duration_since(SystemTime::UNIX_EPOCH)+        .map(|d| d.as_secs())+        .unwrap_or(0);+    let (days, secs) = (now / 86_400, 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;+    }+    format!(+        "{y:04}{:02}{:02}-{:02}{:02}{:02}",+        m + 1,+        day + 1,+        secs / 3600,+        (secs % 3600) / 60,+        secs % 60+    )+}++/// THE PERSISTENT STATE, named. Not a whole legacy 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. (`migrated-from-legacy.json` is deliberately absent: every root keeps+/// its own record of what IT copied.)+const MIGRATED_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",+];++/// The name a copy takes when its own name is taken — by an earlier copy of the+/// same file that has since CHANGED, or by a different file of the same name from+/// another legacy root. Never an existing name.+fn beside_name(dest: &Path, rel: &Path, stamp: &str) -> PathBuf {+    let base = dest.join(rel);+    let name = base.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+    let mut n = 1u32;+    loop {+        let candidate = base.with_file_name(if n == 1 {+            format!("{name}.legacy-{stamp}")+        } else {+            format!("{name}.legacy-{stamp}-{n}")+        });+        if !candidate.exists() && !candidate.with_file_name(format!(+            "{}.migrating",+            candidate.file_name().map(|c| c.to_string_lossy().to_string()).unwrap_or_default()+        )).exists()+        {+            return candidate;+        }+        n += 1;+    }+}++/// ONE COPY, the only way this migration makes one: under a temporary name,+/// verified byte for byte, then renamed into place — so a start that dies+/// mid-copy (a crash, a power cut, ab killing a bridge slow to answer `/status`)+/// never leaves a truncated file under the REAL name, where never-overwrite would+/// protect it forever. Returns the sha256 of what landed. On failure the+/// temporary is removed, and any folder that leaves empty goes too, so the+/// listing never shows an empty rescue beside the real one.+fn copy_verified(from: &Path, to: &Path, dest: &Path) -> Result<String, String> {+    if let Some(parent) = to.parent() {+        let _ = std::fs::create_dir_all(parent);+    }+    let tmp = to.with_file_name(format!(+        "{}.migrating",+        to.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()+    ));+    // A leftover from an interrupted start: its original is right here, so it+    // is simply replaced.+    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 why = if !copied_ok {+        Some("the copy could not be made")+    } else if !same_bytes(from, &tmp) {+        Some("copy did not verify byte for byte")+    } else if to.exists() || std::fs::rename(&tmp, to).is_err() {+        Some("the verified copy could not be renamed into place")+    } else {+        None+    };+    match why {+        None => crate::sha256::hex_of_file(to).ok_or_else(|| "the copy could not be read back".into()),+        Some(why) => {+            // A COPY THAT DOES NOT MATCH IS NOT A COPY. The original is still in+            // the legacy location, untouched, and the next start tries it again.+            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());+            }+            Err(why.to_string())+        }+    }+}++/// COPY WHAT THE LEGACY ROOTS HOLD INTO THE DATA ROOT.+///+/// Legacy roots are taken OLDEST FIRST, files in a fixed order. Every copy goes+/// through `copy_verified`. The originals are LEFT where they were: this runs+/// against directories somebody else is about to overwrite or uninstall, but it+/// is not this code's place to delete anything from them.+///+/// EACH FILE IS RECORDED, by (legacy root, path, size, mtime, sha256 of the+/// copy), in `migrated-from-legacy.json` — cumulative, so a copy the user deletes+/// stays deleted. On a later start a recorded file is compared, not skipped: ///-/// 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.+///   * the same size and mtime → settled, nothing to do;+///   * DIFFERENT → it CHANGED SINCE ITS COPY (0.9.0 settled by name alone, and+///     lost exactly the two files that change by nature: a rollback's appends to+///     the old ledger, a rewritten ownership registry). It is copied AGAIN,+///     beside the first copy as `<name>.legacy-<stamp>` — never over it — and+///     reported under `changedSinceCopy`, in the marker, the ledger and+///     `swx_recover_list`. ///-/// 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.+/// A file NOT recorded yet whose name is already taken in the data root — by a+/// byte-identical file, it is recorded as present; by a DIFFERENT one (a+/// same-named file from the other legacy root), it is copied beside it, never+/// over it and never dropped. A copy that fails is not recorded, so the next+/// start tries it again. 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) {+    let legacies: Vec<PathBuf> = legacy_roots()+        .into_iter()+        .filter(|l| l.is_dir() && !is_within(&dest, l) && !is_within(l, &dest))+        .collect();+    if legacies.is_empty() {         return None;     }     let marker = dest.join("migrated-from-legacy.json");@@ -205,33 +437,27 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {     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()))+    // THE SETTLED RECORDS, one per (legacy root, file). A record written before+    // the root was recorded belongs to the first legacy root — the only one there+    // was.+    let first_root = legacies[0].to_string_lossy().to_string();+    let mut records: Vec<Value> = list("copied")+        .into_iter()+        .map(|mut r| {+            if r.get("outcome").is_none() {+                r["outcome"] = json!("copied");+            }+            r+        })+        .chain(list("skipped").into_iter().map(|mut r| {+            if r.get("outcome").is_none() {+                r["outcome"] = json!("present");+            }+            r+        }))         .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",-    ];+    let mut changed_history: Vec<Value> = list("changedSinceCopy");+     // 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);@@ -245,103 +471,157 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {             }         }     }-    let mut files: Vec<PathBuf> = Vec::new();-    for item in ITEMS {-        if legacy.join(item).exists() {-            walk(&legacy, Path::new(item), &mut files, 0);+    let mut candidates: Vec<(PathBuf, PathBuf)> = Vec::new();+    for legacy in &legacies {+        let mut files: Vec<PathBuf> = Vec::new();+        for item in MIGRATED_ITEMS {+            if legacy.join(item).exists() {+                walk(legacy, Path::new(item), &mut files, 0);+            }         }+        // 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();+        candidates.extend(files.into_iter().map(|f| (legacy.clone(), f)));     }-    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 stamp = stamp_now();+    let now_ms = crate::launchos::epoch_ms() as u64;+    let (mut copied, mut skipped, mut failed, mut changed) =+        (Vec::<Value>::new(), Vec::<Value>::new(), Vec::<Value>::new(), Vec::<Value>::new());+    let rel_of = |p: &Path| p.strip_prefix(&dest).unwrap_or(p).to_string_lossy().to_string();+    for (legacy, rel) in candidates {         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"}));+        let lroot = legacy.to_string_lossy().to_string();+        let Ok(meta) = std::fs::metadata(&from) else {+            failed.push(json!({"legacyRoot": lroot, "file": name, "why": "the original could not be read"}));             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 bytes = meta.len();+        let mtime = modified_ms(&from.to_string_lossy());+        let at = records.iter().position(|r| {+            r.get("file").and_then(|f| f.as_str()) == Some(name.as_str())+                && r.get("legacyRoot").and_then(|v| v.as_str()).unwrap_or(&first_root) == lroot+        });+        match at {+            Some(i) => {+                let was_bytes = records[i].get("bytes").and_then(|v| v.as_u64());+                let was_mtime = records[i].get("modifiedMs").and_then(|v| v.as_u64());+                // A record that measured nothing cannot be compared: it stays+                // settled, as the record that wrote it settled it.+                let differs = matches!(was_bytes, Some(b) if b != bytes)+                    || matches!((was_mtime, mtime), (Some(a), Some(b)) if a != b);+                if !differs {+                    continue;+                }+                // CHANGED SINCE ITS COPY: copied again, beside, never over.+                let beside = beside_name(&dest, &rel, &stamp);+                match copy_verified(&from, &beside, &dest) {+                    Ok(sha) => {+                        let entry = json!({+                            "legacyRoot": lroot,+                            "file": name,+                            "was": {+                                "bytes": was_bytes,+                                "modifiedMs": was_mtime,+                                "sha256": records[i].get("sha256").cloned().unwrap_or(Value::Null),+                                "copiedAs": records[i].get("copiedAs").cloned().unwrap_or(json!(name)),+                            },+                            "now": {"bytes": bytes, "modifiedMs": mtime, "sha256": sha},+                            "copiedAs": rel_of(&beside),+                            "atMs": now_ms,+                        });+                        changed.push(entry.clone());+                        changed_history.push(entry);+                        // The record now describes the LATEST copy, so the next+                        // start compares against what it last saw.+                        let r = &mut records[i];+                        r["bytes"] = json!(bytes);+                        r["modifiedMs"] = json!(mtime);+                        r["sha256"] = json!(sha);+                        r["copiedAs"] = json!(rel_of(&beside));+                        r["outcome"] = json!("copiedBesideAfterChange");+                    }+                    Err(why) => failed.push(json!({+                        "legacyRoot": lroot, "file": name, "why": why, "changedSinceCopy": true,+                    })),+                }             }-        }-        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;+            None => {+                let to = dest.join(&rel);+                let (target, outcome, why) = if !to.exists() {+                    (to.clone(), "copied", None)+                } else if same_bytes(&from, &to) {+                    let entry = json!({+                        "legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,+                        "sha256": crate::sha256::hex_of_file(&to), "copiedAs": name,+                        "outcome": "present",+                        "why": "already present in the data root, byte for byte",+                    });+                    skipped.push(entry.clone());+                    records.push(entry);+                    continue;+                } else {+                    // SAME NAME, DIFFERENT BYTES: never over it, never dropped.+                    (+                        beside_name(&dest, &rel, &stamp),+                        "copiedBeside",+                        Some("a different file of this name was already in the data root; copied beside it, never over it"),+                    )+                };+                match copy_verified(&from, &target, &dest) {+                    Ok(sha) => {+                        let mut entry = json!({+                            "legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,+                            "sha256": sha, "copiedAs": rel_of(&target), "outcome": outcome,+                        });+                        if let Some(why) = why {+                            entry["why"] = json!(why);+                        }+                        copied.push(entry.clone());+                        records.push(entry);+                    }+                    Err(why) => failed.push(json!({"legacyRoot": lroot, "file": name, "why": why})),                 }-                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.";+    if copied.is_empty() && skipped.is_empty() && failed.is_empty() && changed.is_empty() {+        return None;+    }+    let why = "older bridges kept their data inside ab's directories: 0.8.x in the bridge cache, \+               which every update re-extracts (measured 2026-09-30: the 2026-09-18 auto-install \+               of 0.8.9 deleted every recovery folder from the day before), and 0.9.0 in \+               Adom Bridge\\solidworks-data, which ab's installer and uninstaller own. Data now \+               lives in a directory only this bridge writes to.";+    let legacy_list: Vec<String> = legacies.iter().map(|l| l.to_string_lossy().to_string()).collect();     let report = json!({-        "from": legacy.to_string_lossy(),+        "from": legacy_list,         "to": dest.to_string_lossy(),         "copied": copied,         "failed": failed,         "skipped": skipped,+        "changedSinceCopy": changed,         "originalsLeftInPlace": true,         "why": why,     });-    // THE CUMULATIVE RECORD: everything ever settled, and what is still failing.+    // THE CUMULATIVE RECORD: everything ever settled, every change ever seen, and+    // what is still failing.     let record = json!({-        "from": legacy.to_string_lossy(),+        "markerVersion": 2,+        "from": legacy_list,         "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<_>>(),+        "copied": records.iter().filter(|r| r["outcome"] != "present").cloned().collect::<Vec<_>>(),+        "skipped": records.iter().filter(|r| r["outcome"] == "present").cloned().collect::<Vec<_>>(),+        "changedSinceCopy": changed_history,         "failed": failed,         "originalsLeftInPlace": true,-        "lastRunAtMs": crate::launchos::epoch_ms() as u64,+        "lastRunAtMs": now_ms,         "why": why,     });     if let Ok(text) = serde_json::to_string_pretty(&record) {+        let _ = std::fs::create_dir_all(&dest);         let _ = crate::launch::write_atomic(&marker, &text);     }     Some(report)
src/server.rs+43−12
@@ -62,25 +62,55 @@ 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+    // MOVE THE DATA OUT OF ab's DIRECTORIES, BEFORE ANYTHING READS OR WRITES IT.+    // The legacy roots are 0.8.x's install cache, 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), and 0.9.0's `Adom Bridge\solidworks-data`,+    // inside the directory ab's installer owns. 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.+    // before it would make the migration copy the real one BESIDE it rather than+    // into place.     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);+        let count = |k: &str| report[k].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()+            "[swx] migrated {} file(s) from older bridges' locations into {} ({} changed since \+             an earlier copy and were copied again beside it; {} failed verification and were \+             NOT copied; originals left in place)",+            count("copied"),+            crate::scratch::root().display(),+            count("changedSinceCopy"),+            count("failed"),         );+        // A CHANGE IS ITS OWN LINE IN THE LEDGER, so the record of "something+        // wrote to an older location after we copied it" can be found without+        // reading every migration report.+        if count("changedSinceCopy") > 0 {+            crate::ledger::record(+                "legacy_file_changed_since_copy",+                serde_json::json!({+                    "files": report["changedSinceCopy"].clone(),+                    "why": "a file in an older bridge's location changed after it was copied \+                            into the data root — a rollback's ledger entries, a rewritten \+                            registry — so it was copied again, beside the first copy, never \+                            over it",+                }),+            );+        }         crate::ledger::record("data_migrated_from_install_cache", report);     }+    // AND SAY SO IF THE ROOT FELL BACK TO THE TEMP DIRECTORY — every sweep refuses+    // there (`data_root_is_temp`), and this is the line that explains why.+    if crate::scratch::root_is_temp_fallback() {+        println!(+            "[swx] WARNING data root {} is in the OS temp directory (neither LOCALAPPDATA nor \+             USERPROFILE is set), which Windows empties on a schedule: every safe-close sweep \+             will refuse with data_root_is_temp rather than put the only copy of unsaved work \+             there.",+            crate::scratch::root().display()+        );+    }     // 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.@@ -103,7 +133,8 @@ pub fn run(port: Option<u16>) {     let ctx = Arc::new(Ctx::new(detection));     crate::scratch::prune(); -    println!("[swx] data root {}", crate::scratch::root().display());+    let (data_root, source) = crate::scratch::root_and_source();+    println!("[swx] data root {} (from {})", data_root.display(), source.name());     println!("[swx] listening on http://{host}:{bound} — /status and /command");      let mut workers = Vec::new();
src/sha256.rsadded+165
@@ -0,0 +1,165 @@+//! SHA-256, in plain Rust — for recording WHICH bytes a migration copied.+//!+//! `migrate_legacy` records every file it copies by (path, size, mtime, sha256 of+//! the copy), so a later start can tell "the same file" from "a file that has+//! changed since it was copied". The size and mtime are what it compares; the+//! digest is what lets a person — or the next adversary — prove afterwards which+//! bytes the copy held. No crate for it: the dependency list is two crates on+//! purpose, and FIPS 180-4 is short. Checked against the standard vectors below.++const K: [u32; 64] = [+    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,+    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,+    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,+    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,+    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,+    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,+    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,+    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,+];++/// A streaming SHA-256.+pub struct Sha256 {+    h: [u32; 8],+    buf: [u8; 64],+    buf_len: usize,+    total: u64,+}++impl Default for Sha256 {+    fn default() -> Self {+        Self::new()+    }+}++impl Sha256 {+    pub fn new() -> Sha256 {+        Sha256 {+            h: [+                0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,+                0x5be0cd19,+            ],+            buf: [0; 64],+            buf_len: 0,+            total: 0,+        }+    }++    fn block(&mut self, block: &[u8]) {+        let mut w = [0u32; 64];+        for (i, word) in w.iter_mut().take(16).enumerate() {+            *word = u32::from_be_bytes([block[4 * i], block[4 * i + 1], block[4 * i + 2], block[4 * i + 3]]);+        }+        for i in 16..64 {+            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);+            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);+            w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);+        }+        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.h;+        for i in 0..64 {+            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);+            let ch = (e & f) ^ (!e & g);+            let t1 = h.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);+            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);+            let maj = (a & b) ^ (a & c) ^ (b & c);+            let t2 = s0.wrapping_add(maj);+            h = g;+            g = f;+            f = e;+            e = d.wrapping_add(t1);+            d = c;+            c = b;+            b = a;+            a = t1.wrapping_add(t2);+        }+        for (x, y) in self.h.iter_mut().zip([a, b, c, d, e, f, g, h]) {+            *x = x.wrapping_add(y);+        }+    }++    pub fn update(&mut self, mut data: &[u8]) {+        self.total = self.total.wrapping_add(data.len() as u64);+        if self.buf_len > 0 {+            let take = (64 - self.buf_len).min(data.len());+            self.buf[self.buf_len..self.buf_len + take].copy_from_slice(&data[..take]);+            self.buf_len += take;+            data = &data[take..];+            if self.buf_len == 64 {+                let block = self.buf;+                self.block(&block);+                self.buf_len = 0;+            }+        }+        while data.len() >= 64 {+            self.block(&data[..64]);+            data = &data[64..];+        }+        if !data.is_empty() {+            self.buf[..data.len()].copy_from_slice(data);+            self.buf_len = data.len();+        }+    }++    pub fn hex(mut self) -> String {+        let bits = self.total.wrapping_mul(8);+        let mut pad = vec![0x80u8];+        let rem = (self.total as usize + 1) % 64;+        let zeros = if rem <= 56 { 56 - rem } else { 120 - rem };+        pad.extend(std::iter::repeat(0u8).take(zeros));+        pad.extend_from_slice(&bits.to_be_bytes());+        // `update` would count the padding into the length; feed it raw.+        let total = self.total;+        self.update(&pad);+        self.total = total;+        self.h.iter().map(|x| format!("{x:08x}")).collect()+    }+}++/// The digest of a byte slice.+pub fn hex_of(data: &[u8]) -> String {+    let mut s = Sha256::new();+    s.update(data);+    s.hex()+}++/// The digest of a file, streamed; `None` when it cannot be read.+pub fn hex_of_file(path: &std::path::Path) -> Option<String> {+    use std::io::Read;+    let mut f = std::fs::File::open(path).ok()?;+    let mut s = Sha256::new();+    let mut buf = vec![0u8; 1 << 20];+    loop {+        let n = f.read(&mut buf).ok()?;+        if n == 0 {+            return Some(s.hex());+        }+        s.update(&buf[..n]);+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn the_standard_vectors() {+        assert_eq!(hex_of(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");+        assert_eq!(hex_of(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");+        assert_eq!(+            hex_of(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),+            "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"+        );+        let million = vec![b'a'; 1_000_000];+        assert_eq!(hex_of(&million), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");+    }++    #[test]+    fn streaming_in_odd_pieces_gives_the_same_digest() {+        let data: Vec<u8> = (0..10_000u32).map(|i| (i * 7 % 256) as u8).collect();+        let mut s = Sha256::new();+        for chunk in data.chunks(37) {+            s.update(chunk);+        }+        assert_eq!(s.hex(), hex_of(&data));+    }+}
src/verbs3.rs+205−64
@@ -772,19 +772,38 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {     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.+    // NOT WHERE THE OS CLEANS. With no user profile location at all the data root+    // falls back to the temp directory, which Storage Sense and Disk Cleanup empty+    // on a schedule BY DESIGN — the one standard place the only copy of unsaved+    // work must never go. Refused before anything is written or closed.+    if crate::scratch::root_is_temp_fallback() {+        return err_code(+            "data_root_is_temp",+            format!(+                "refusing to write recovery files to {}: neither LOCALAPPDATA nor USERPROFILE is \+                 set, so the bridge's data root fell back to the OS temp directory, which Windows \+                 empties on a schedule. A recovery file is the user's only copy of unsaved work.",+                crate::scratch::root().display()+            ),+            "Nothing was preserved and nothing was closed. Start the bridge with LOCALAPPDATA \+             (or USERPROFILE) set — it is set in every normal Windows user session — or point \+             SWX_SCRATCH_DIR at a directory nothing cleans, and run this again.",+        );+    }+    // NOT UNDER AN INSTALL LOCATION. A recovery folder is the user's only copy+    // of unsaved work. The install directory is re-extracted on update (measured+    // 2026-09-30: the 2026-09-18 auto-install of 0.8.9 deleted every recovery+    // folder from the day before), and ab's own directory belongs to ab's+    // installer and uninstaller (adversary pass 39). The data root is outside both+    // 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.     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.",+            "Nothing was preserved and nothing was closed. Point the bridge's data root at a \+             directory only it writes to — the default is %LOCALAPPDATA%\\adom-solidworks-bridge \+             — and run this again.",         );     }     let job = ctx.jobs.start("close_all_safely", "Saving every document, then closing it", 120);@@ -1307,6 +1326,18 @@ fn ctx_stop(ctx: &Ctx, args: &Value) -> Value {     crate::verbs::dispatch(ctx, "swx_stop", &a) } +/// The base name of a copy kept BESIDE another by the migration —+/// `x.SLDPRT.legacy-20261001-003000[-N]` → `x.SLDPRT` — or `None` for any other+/// name.+fn beside_base(name: &str) -> Option<&str> {+    let i = name.rfind(".legacy-")?;+    let tail = &name[i + ".legacy-".len()..];+    let stamp_like = tail.len() >= 15+        && tail.chars().all(|c| c.is_ascii_digit() || c == '-')+        && tail.as_bytes()[8] == b'-';+    (stamp_like && i > 0).then(|| &name[..i])+}+ /// ONE DOCUMENT A SWEEP CLOSED IN PLACE, as the listing reports it — or `None` /// for a manifest entry that is not one. ///@@ -1433,26 +1464,40 @@ fn closed_in_place_entry(e: &Value) -> Option<Value> { 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()+    // THE LEGACY LOCATIONS TOO, oldest first: 0.8.x's install cache, which an+    // update re-extracts, and 0.9.0's `Adom Bridge\solidworks-data`, inside the+    // directory ab's installer owns. Anything in them is migrated on start — but+    // a rescue an older bridge wrote after that, 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 legacies: Vec<(std::path::PathBuf, &'static str)> = crate::scratch::legacy_roots()+        .into_iter()         .map(|l| l.join("recovery"))-        .filter(|l| l != &root);+        .filter(|l| l != &root)+        .enumerate()+        .map(|(i, l)| {+            (l, match i {+                0 => "legacyInstallCache",+                1 => "legacyDataRoot",+                _ => "legacy",+            })+        })+        .collect();+    // WHERE AN OLDER LOCATION STILL HOLDS a file of the same sweep and name — the+    // original of a copy in the data root, if there is one left.+    let original_of = |sweep: &std::ffi::OsStr, name: &str| -> Option<std::path::PathBuf> {+        legacies.iter().map(|(l, _)| l.join(sweep).join(name)).find(|p| p.is_file())+    };     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 };+    for (base, location) in std::iter::once((root.clone(), "data")).chain(legacies.iter().cloned()) {         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.+    // Newest first, across every location.     dirs.sort_by(|a, b| b.0.file_name().cmp(&a.0.file_name()));     {         for (d, location) in dirs {@@ -1499,11 +1544,7 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {                     // 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())+                            d.file_name().and_then(|sweep| original_of(sweep, real))                         } else {                             None                         };@@ -1521,21 +1562,51 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {                             "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.",+                                     original is still at {}, 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."+                                    "PARTIAL COPY of {real}: a migration out of an older bridge's \+                                     location 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;                     }+                    // A COPY KEPT BESIDE ANOTHER (see `migrate_legacy`): a later+                    // version of a file found in an older location after its first+                    // copy, or a different file of the same name. Its name carries+                    // `.legacy-<stamp>`, so it is verified as the kind its base name+                    // says — and it is said to be what it is.+                    if let Some(base) = beside_base(&name) {+                        // A RECORD kept beside a record is not a rescue either.+                        if base == MANIFEST_NAME {+                            continue;+                        }+                        let kind = crate::disk::DocKind::from_extension(base);+                        let v = crate::comhost::host::recovery_file_verdict(+                            &p.to_string_lossy(),+                            kind,+                            None,+                        );+                        let mut e = reverified_entry(&p, &name, bytes, kind, &v);+                        e["copiedBeside"] = json!(true);+                        e["besideOf"] = json!(base);+                        e["howToOpen"] = json!(format!(+                            "A COPY KEPT BESIDE {base}: an older bridge's location held a \+                             DIFFERENT version of {base} after it had been copied once (or a \+                             different file of that name), so it was copied again under this \+                             name rather than over the first. Check both before relying on \+                             either. {}",+                            e["howToOpen"].as_str().unwrap_or("")+                        ));+                        files.push(e);+                        continue;+                    }                     let entry = entries.iter().find(|e| {                         e.get("name").and_then(|v| v.as_str()).map(|n| n == name).unwrap_or(false)                     });@@ -1670,11 +1741,8 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {             // 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))-                {+                // Listed already, under its own name.+                if name.is_empty() || files.iter().any(|f| f["name"] == json!(name)) {                     continue;                 }                 // NOT GONE — NOT COPIED YET. A sweep migrated from ab's install@@ -1685,14 +1753,53 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {                 // 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())+                    d.file_name().and_then(|sweep| original_of(sweep, &name))                 } else {                     None                 };+                // ONLY A PARTIAL COPY OF IT EXISTS HERE — an interrupted migration+                // left `<name>.migrating` beside where it should be. That is not+                // "moved or deleted by something else": the copy never finished,+                // and the entry says so, pointing at the partial bytes and at the+                // original if an older location still has it.+                let partial = files+                    .iter()+                    .find(|f| f["partialCopy"] == true && f["copyOf"] == json!(name))+                    .map(|f| f["name"].clone());+                if let Some(partial_name) = partial {+                    let partial_name = partial_name.as_str().unwrap_or("").to_string();+                    files.push(json!({+                        "path": Value::Null,+                        "name": name,+                        "bytes": Value::Null,+                        "source": "manifest",+                        "present": false,+                        "partialCopyOnly": true,+                        "partialCopy": partial_name,+                        "legacyPath": at_legacy.as_ref().map(|o| json!(o.to_string_lossy())).unwrap_or(Value::Null),+                        "title": e.get("title").cloned().unwrap_or(Value::Null),+                        "verified": false,+                        "verifiedReason": "the copy of this file out of an older bridge's location \+                                           never finished; only incomplete bytes are here",+                        "howToOpen": match &at_legacy {+                            Some(o) => format!(+                                "NOT COPIED YET — AN EARLIER COPY WAS INTERRUPTED. {partial_name} \+                                 holds incomplete bytes; the whole original is still at {}, and \+                                 the bridge copies it again on its next start. If this file \+                                 matters, copy the original out of that folder now.",+                                o.display()+                            ),+                            None => format!(+                                "ONLY A PARTIAL COPY EXISTS. Copying this file out of an older \+                                 bridge's location was interrupted, and the original is no \+                                 longer there. Nothing moved or deleted it from this folder — \+                                 the copy never finished — and {partial_name}, beside it, holds \+                                 the INCOMPLETE bytes that remain."+                            ),+                        },+                    }));+                    continue;+                }                 if let Some(original) = at_legacy {                     files.push(json!({                         "path": Value::Null,@@ -1704,14 +1811,14 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {                         "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`",+                        "verifiedReason": "this file has not been copied out of an older bridge's \+                                           location 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.",+                            "NOT COPIED YET. The only copy of this file is still at {}, inside \+                             ab's directory, where an update of the bridge or of ab can DELETE \+                             it. The bridge retries the copy on its next start; if this file \+                             matters, copy it out of that folder now.",                             original.display()                         ),                     }));@@ -1736,7 +1843,8 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {             // 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";+            let endangered = location != "data";+            let from_update = 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@@ -1761,38 +1869,71 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {             } else {                 None             };+            // WHAT CAN TAKE IT: the bridge cache is re-extracted by every update+            // of the bridge; ab's own directory is ab's installer's and+            // uninstaller's, and what they do to it is undocumented.+            let threat = if from_update {+                "ab's INSTALL CACHE, which is re-extracted — and emptied — whenever the bridge \+                 updates"+            } else {+                "ab's own directory, which ab's installer and uninstaller own — what an ab \+                 uninstall, repair or reinstall does to it is undocumented"+            };             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.+                // WHERE IT LIVES, because the older places belong to something+                // else.                 "location": location,-                "atRiskFromUpdate": endangered,+                "atRisk": endangered,+                "atRiskFromUpdate": from_update,                 "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.",+                        "This is the ORIGINAL of a rescue written by an older bridge, left in \+                         {threat}. 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."-                    ),+                    (true, None) => json!(format!(+                        "This rescue is in {threat}, and there is NO copy of it in the data root. \+                         It was written by an older bridge. 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,             }));         }     }+    // THE MIGRATION'S OWN RECORD: what changed in an older location after it was+    // copied (and was copied again, beside the first copy), and what is still+    // failing to copy. Everything in it is about files whose originals live where+    // something else decides their fate.+    let marker: Value = std::fs::read_to_string(crate::scratch::root().join("migrated-from-legacy.json"))+        .ok()+        .and_then(|t| serde_json::from_str::<Value>(&t).ok())+        .unwrap_or(Value::Null);+    let migration = if marker.is_null() {+        Value::Null+    } else {+        json!({+            "changedSinceCopy": marker.get("changedSinceCopy").cloned().unwrap_or(json!([])),+            "failed": marker.get("failed").cloned().unwrap_or(json!([])),+            "lastRunAtMs": marker.get("lastRunAtMs").cloned().unwrap_or(Value::Null),+            "_note": "changedSinceCopy: a file in an older bridge's location that changed AFTER \+                      it was copied here (a rollback's ledger entries, a rewritten registry). It \+                      was copied again, beside the first copy (`copiedAs`), never over it. failed: \+                      files still only in the older location; the bridge retries them on start.",+        })+    };     ok(json!({         "recoveryRoot": root.to_string_lossy(),-        "legacyRecoveryRoot": legacy.as_ref().map(|l| json!(l.to_string_lossy())).unwrap_or(Value::Null),+        "legacyRecoveryRoot": legacies.first().map(|(l, _)| json!(l.to_string_lossy())).unwrap_or(Value::Null),+        "legacyRecoveryRoots": legacies.iter().map(|(l, loc)| json!({"path": l.to_string_lossy(), "location": loc})).collect::<Vec<_>>(),+        "migration": migration,         "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
tests/adversary37.rsadded+388
@@ -0,0 +1,388 @@+//! ADVERSARIAL REVIEW OF 0.9.0 — pass thirty-nine.+//!+//! 0.9.0 is PUBLISHED and adom-bridge 2.1.163 auto-installs it everywhere, so+//! the question that governs this pass is whether anything here makes the+//! release worse than the 0.8.9 it replaces. It does not. Everything below is+//! about what 0.9.0 does not yet protect, not about what it breaks.+//!+//! WHAT CHECKS OUT, read and — where compiled here — exercised.+//!+//!  * THE STARTUP ORDER. `migrate_legacy` runs after the bind (a duplicate+//!    instance exits before touching anything) and BEFORE `Ctx::new` restores+//!    the ownership registry, so nothing is written into the data root ahead of+//!    the migration and then used to "skip" the real legacy file.+//!    (server.rs:74; its own comment names exactly that hazard.)+//!  * CRASH BETWEEN RENAME AND MARKER. The marker is written once, after the+//!    loop. A start that dies after some renames re-finds those files on the next+//!    start, sees `to.exists()`, and records them `skipped: already present`.+//!    Never copied twice, never lost — the renamed copy was byte-verified before+//!    the rename. Right.+//!  * CAN IT DELETE AN ORIGINAL? No. Nothing in `migrate_legacy` writes to or+//!    removes from the legacy tree. Removal is confined to the `.migrating`+//!    temporary and EMPTY directories under `dest`, guarded by+//!    `d.starts_with(&dest)`; `rel` is built from `read_dir` entries, which can+//!    carry neither a separator nor `..`. A user deleting a copy leaves the+//!    original untouched and the file stays settled, as documented.+//!  * JUNCTIONS. `resolved()` canonicalises the nearest existing ancestor, so a+//!    link INSIDE the data root that points INTO the install dir is caught+//!    (the path resolves inside and is refused), and a link inside the install+//!    dir that points OUT to the data root is correctly allowed, because the+//!    bytes live outside. (What `refuse_inside_install` cannot see is the third+//!    case: whether whatever deletes the install dir FOLLOWS such a link. That is+//!    ab's deleter, not this bridge's check.)+//!  * `recover_list`'s five states. `notCopiedYet` is `verified: null`.+//!    `partialCopy` carries a `howToOpen`, but it says INCOMPLETE / "SOLIDWORKS+//!    is unlikely to open them" and never offers to open anything. One document+//!    CAN appear in two states: a `.migrating` temporary whose original has since+//!    been re-extracted away lists as `partialCopy` under `X.migrating` AND as+//!    GONE under `X` — and GONE says "moved or deleted by something else", which+//!    is not what happened; the bytes that exist are the entry beside it. Wording+//!    only, low.+//!  * `closedInPlace` (target 3). `closed` is matched with `same_path` on the FULL+//!    path, case- and separator-insensitive, and both sides come from the same+//!    sweep's `GetPathName`. Two in-place documents with one basename in two+//!    directories stay distinct; a case difference matches, as Windows means it.+//!    A document open twice would mark both records closed after one close — and+//!    is unreachable, because SOLIDWORKS refuses the second open (65536).+//!  * THE GATES (target 4). The fast-empty retry lives only in `ps()`; routed+//!    `verb()` calls never retry, so no swx verb is ever sent twice. Every `ps()`+//!    script in the drain gate is idempotent (fixed names, `-Force`, `Test-Path`+//!    guards) and echoes on success, so "exit 1, empty stdout AND stderr, under+//!    5 s" really does mean the shell never ran. The unwrap only fires on a+//!    DICT `result`, and no swx verb returns a top-level `result` dict (the only+//!    two are strings nested in rung objects), so it cannot eat a real reply. A+//!    non-dict `result` falls through to the wrapper, where `success` is absent,+//!    which every gate treats as failure: fail-safe, at the cost of the specific+//!    `errorCode` branch (e.g. the interconnect redirect).++mod common;+use common::*;+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++fn s(v: &Value) -> String {+    serde_json::to_string_pretty(v).unwrap_or_default()+}++fn write(path: &Path, bytes: &[u8]) {+    if let Some(p) = path.parent() {+        std::fs::create_dir_all(p).unwrap();+    }+    std::fs::write(path, bytes).unwrap();+}++/// `%LOCALAPPDATA%` with ab's layout under it, as `tests/data_root.rs` builds it.+struct Layout {+    local: TempDir,+}+impl Layout {+    fn new(tag: &str) -> Layout {+        Layout { local: TempDir::new(tag) }+    }+    fn ab_install(&self) -> PathBuf {+        self.local.0.join("Adom Bridge")+    }+    // BUILDER NOTE (0.9.1): finding 1 is fixed by MOVING the data root out of+    // `Adom Bridge\` to a sibling, `%LOCALAPPDATA%\adom-solidworks-bridge`, so+    // `data()` follows it. Every test below that reads "the data root" means the+    // root the bridge uses, and that is now the sibling; 0.9.0's location became+    // the second legacy root, which tests/data_root.rs exercises as such. The+    // assertions themselves are unchanged.+    fn data(&self) -> PathBuf {+        self.local.0.join("adom-solidworks-bridge")+    }+    fn legacy(&self) -> PathBuf {+        self.ab_install().join("bridges-cache").join("solidworks")+    }+}++/// SEVERITY 1, CONDITIONAL ON ONE UNMEASURED FACT — THE DATA ROOT IS INSIDE THE+/// DIRECTORY AB INSTALLS ITSELF INTO.+///+/// What ab's own documentation says, quoted as CLAIMS (none of this is+/// measured here):+///+///   * `adom-bridge-installer`: "It installs to `%LOCALAPPDATA%\Adom Bridge\`"+///     and "The uninstaller lives at `%LOCALAPPDATA%\Adom Bridge\uninstall.exe`".+///   * `adom-bridge-sdk-publish`: bridges unzip "into+///     `%LOCALAPPDATA%\Adom Bridge\bridges-cache\<name>\`".+///   * `adom-bridge-sdk-manifest`: "ab removes its own `bridges-cache` copy of+///     you; everything outside that only you know about."+///   * NOT documented anywhere I can find: what ab's NSIS uninstaller does to the+///     rest of `%LOCALAPPDATA%\Adom Bridge\`. The installer skill describes+///     PREUNINSTALL (PATH strip, the `bridge_uninstall_all` cascade) and+///     POSTUNINSTALL stages, and says nothing about removing — or keeping — the+///     directory's other contents. (And the docs disagree about the cache+///     itself: `adom-bridge-bridges-guide` says updates land in+///     `~/.adom/bridges-cache/`. The live measurement matches sdk-publish.)+///+/// 0.9.0 moved the data root from `Adom Bridge\bridges-cache\solidworks\` to+/// `Adom Bridge\solidworks-data\`. That is out of the directory ab re-extracts+/// on every BRIDGE update — the measured SEV-1, and it is genuinely fixed — and+/// into the directory ab's own INSTALLER owns. The recovery folder is the only+/// copy of unsaved work; the ledger is the one artifact `swx_uninstall` promises+/// to outlive the bridge. Whether both survive an ab uninstall, an ab repair, or+/// the most common troubleshooting move there is — "delete the Adom Bridge+/// folder and reinstall" — now depends on the uninstaller of a different+/// product, whose behaviour for that directory is undocumented.+///+/// That is the same shape as the SEV-1 this release exists to fix, one level+/// up: data placed inside a directory whose lifecycle another component+/// controls. Measure it (one ab uninstall on a scratch profile, check the+/// folder), or move the root out of `Adom Bridge\` altogether — a sibling under+/// `%LOCALAPPDATA%` that nothing but this bridge will ever write, whose only+/// deleter is a human. The migration machinery is good enough to do the second+/// move cheaply, provided `legacy_root()` learns there are now two legacy roots.+///+/// SHOULD 0.9.0 STAY PUBLISHED: yes. Bridge updates happen every four hours+/// (per ab's docs); an ab uninstall is rare. 0.9.0 is strictly safer than+/// 0.8.9, and pulling it would put every machine back in the location that is+/// wiped on every update.+#[test]+fn the_data_root_is_outside_the_directory_ab_installs_itself_into() {+    let lay = Layout::new("adv37-placement");+    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);++    let r = b.call("swx_recover_list", json!({}));+    let root = r["recoveryRoot"].as_str().unwrap_or("").to_string();+    assert!(!root.is_empty(), "precondition: the listing names its root: {}", s(&r));+    assert!(+        Path::new(&root).starts_with(&lay.local.0),+        "precondition: the root was derived from the LOCALAPPDATA given: {root}"+    );++    let inside_ab = Path::new(&root).starts_with(lay.ab_install());+    assert!(+        !inside_ab,+        "the data root {root:?} is inside {:?}, the directory ab's INSTALLER owns — ab's docs: \+         \"It installs to %LOCALAPPDATA%\\Adom Bridge\\\" and \"The uninstaller lives at \+         %LOCALAPPDATA%\\Adom Bridge\\uninstall.exe\". 0.9.0 moved recovery out of the directory \+         ab wipes on every BRIDGE update, which is a real fix; it moved it into the directory ab's \+         uninstaller manages, whose treatment of anything it did not install is undocumented. \+         The recovery folder is the only copy of unsaved work. Same shape as the SEV-1 this \+         release fixes, one level up. Measure one ab uninstall, or move the root to a sibling of \+         `Adom Bridge` that only this bridge writes.",+        lay.ab_install()+    );+}++/// SEVERITY 2 — A LEGACY FILE THAT CHANGES AFTER ITS COPY IS SETTLED BY NAME AND+/// NEVER LOOKED AT AGAIN.+///+/// `migrated-from-legacy.json` settles a file by its RELATIVE PATH. Once+/// `ledger.jsonl` has been copied, it is in `copied` forever, and+/// `files.retain(|rel| !settled.contains(rel))` drops it before any comparison+/// happens. So:+///+///   1. 0.9.0 starts, copies the legacy `ledger.jsonl` — settled.+///   2. The user rolls back to 0.8.9 (the module comment designs for exactly+///      this: "a file that appeared in the legacy location since (an older+///      bridge run again, after a rollback)"). 0.8.9's root IS the legacy+///      location, so every close it performs APPENDS to that ledger, and every+///      document it creates REWRITES `owned-docs.json` there.+///   3. 0.9.0 comes back. The ledger and the registry are settled; the new+///      entries are not copied, not compared, not reported. The next bridge+///      update re-extracts the legacy directory and they are gone.+///+/// The rollback design handles NEW files and not CHANGED ones, and the two files+/// that change by nature — an append-only ledger and a rewritten registry — are+/// precisely the ones this loses. Recovery folders survive (a sweep writes a new+/// folder, so new files), which is why this is not SEV-1: no document's only+/// copy is lost. What is lost is the audit record of what was done to the+/// user's documents during the rollback, and ownership of what 0.8.9 created —+/// which fails safe, since an unowned document is left open, not closed.+///+/// Settle by (path, size, mtime), not path alone. A legacy file whose size or+/// mtime differs from the one recorded when it was copied is "changed since+/// copied": copy it beside the settled one under a distinct name (never+/// overwrite), or at minimum report it — `legacyChangedSinceCopy` — so the+/// listing can warn before the update takes it.+#[test]+fn a_legacy_file_that_changes_after_its_copy_is_not_silently_settled() {+    let lay = Layout::new("adv37-rollback");+    let local = lay.local.path();+    let legacy_ledger = lay.legacy().join("ledger.jsonl");+    write(+        &legacy_ledger,+        b"{\"at\":1789000000,\"action\":\"document_created\",\"detail\":{\"title\":\"BEFORE\"}}\n",+    );+    let env = [("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())];++    // 1. First start: the ledger is migrated and settled.+    {+        let b = Bridge::start_with(&env);+        let _ = b.call("swx_recover_list", json!({}));+    }+    let data_ledger = lay.data().join("ledger.jsonl");+    let first = std::fs::read_to_string(&data_ledger).unwrap_or_default();+    assert!(+        first.contains("BEFORE"),+        "precondition: the first start migrated the legacy ledger: {first:?}"+    );++    // 2. A rollback to 0.8.9 appends to the ledger IN THE LEGACY LOCATION.+    {+        use std::io::Write;+        let mut f = std::fs::OpenOptions::new().append(true).open(&legacy_ledger).unwrap();+        f.write_all(+            b"{\"at\":1790000000,\"action\":\"document_closed\",\"detail\":{\"title\":\"DURING-ROLLBACK\"}}\n",+        )+        .unwrap();+    }++    // 3. 0.9.0 comes back.+    {+        let b = Bridge::start_with(&env);+        let _ = b.call("swx_recover_list", json!({}));+    }+    let after = std::fs::read_to_string(&data_ledger).unwrap_or_default();+    let marker = std::fs::read_to_string(lay.data().join("migrated-from-legacy.json"))+        .unwrap_or_default();++    let carried = after.contains("DURING-ROLLBACK");+    // STRUCTURAL, not a word search: some array in the marker whose key speaks of+    // a change must NAME this file. A bare `contains("changed")` would pass the+    // day an unrelated `why` string used the word — the wrong-reason pass this+    // review has committed four times this cycle.+    let reported = serde_json::from_str::<Value>(&marker)+        .ok()+        .and_then(|m| m.as_object().cloned())+        .map(|m| {+            m.iter().any(|(k, v)| {+                k.to_ascii_lowercase().contains("changed")+                    && v.as_array()+                        .map(|a| a.iter().any(|e| e.to_string().contains("ledger.jsonl")))+                        .unwrap_or(false)+            })+        })+        .unwrap_or(false);+    assert!(+        carried || reported,+        "a ledger entry 0.8.9 appended in the legacy location during a rollback was neither \+         copied into the data root nor reported anywhere: `ledger.jsonl` was settled by NAME on \+         the first start, so the second start drops it before comparing anything. The next \+         update re-extracts the legacy directory and the record of what was done to the user's \+         documents during the rollback is gone. Settle by (path, size, mtime) and report a \+         changed-since-copy file rather than skipping it.\n  data-root ledger now: {after:?}\n  \+         marker: {marker}"+    );+}++/// SEVERITY 3 — THE LAST-RESORT DATA ROOT IS A DIRECTORY WINDOWS CLEANS.+///+/// With neither `SWX_SCRATCH_DIR` nor `LOCALAPPDATA`, `root()` falls back to+/// `std::env::temp_dir().join("adom-bridge-swx-data")` — `%TEMP%` on Windows,+/// which Storage Sense and Disk Cleanup delete from on a schedule the user+/// configured for disposable files. `refuse_inside_install` checks the install+/// directory and ab's cache; nothing checks the fallback. For a user-context+/// process `LOCALAPPDATA` is essentially always set, so this is rare — but when+/// it fires, the recovery folder lands in the one standard location that is+/// cleaned BY DESIGN, with no warning in the log. The startup already prints a+/// WARNING when recovery is inside an install location; the fallback deserves+/// the same line, or a refusal.+/// BUILDER NOTE (0.9.1): adapted to the design decided for this finding — a+/// PROFILE directory before a temp one (`%USERPROFILE%\adom-solidworks-bridge`),+/// and a REFUSAL (`data_root_is_temp`, plus a start-up WARNING) when temp is all+/// there is. As written, this test unset LOCALAPPDATA on a Linux host, where+/// USERPROFILE does not exist either, and demanded a non-temp root anyway: on+/// such a host there is no non-temp location left except the developer's real+/// home directory, which the suite must never write into. So the child is given+/// a profile and a temp directory of its OWN, and the claim is checked against+/// the temp directory the BRIDGE sees: with a profile, the root is the profile's+/// and is not under temp. The no-profile half — temp, and every sweep refusing+/// with nothing written or closed — is pinned in tests/data_root.rs+/// (`with_no_profile_location_every_sweep_refuses_rather_than_write_to_temp`).+#[test]+fn the_fallback_data_root_is_not_a_directory_the_os_cleans() {+    let t = TempDir::new("adv37-fallback");+    let profile = t.0.join("profile");+    let child_tmp = t.0.join("child-tmp");+    std::fs::create_dir_all(&profile).unwrap();+    std::fs::create_dir_all(&child_tmp).unwrap();+    let (p, c) = (profile.to_string_lossy().to_string(), child_tmp.to_string_lossy().to_string());+    let b = Bridge::start_with(&[+        ("SWX_SCRATCH_DIR", ""),+        ("LOCALAPPDATA", ""),+        ("USERPROFILE", p.as_str()),+        ("TMPDIR", c.as_str()),+        ("TMP", c.as_str()),+        ("TEMP", c.as_str()),+    ]);+    let r = b.call("swx_recover_list", json!({}));+    let root = r["recoveryRoot"].as_str().unwrap_or("").to_string();+    assert!(!root.is_empty(), "precondition: the listing names its root: {}", s(&r));++    let tmp = std::fs::canonicalize(&child_tmp).unwrap_or(child_tmp.clone());+    let root_c = std::fs::canonicalize(&root).unwrap_or_else(|_| PathBuf::from(&root));+    assert!(+        !root_c.starts_with(&tmp),+        "with LOCALAPPDATA unset the data root falls back to {root:?}, under the OS temp \+         directory — on Windows %TEMP%, which Storage Sense and Disk Cleanup empty on a \+         schedule. Nothing refuses it and nothing warns. Rare in a user-context process, but when \+         it happens the only copy of unsaved work goes to the one place cleaned by design."+    );+    assert!(+        Path::new(&root).starts_with(&profile),+        "with LOCALAPPDATA unset, the user's profile comes before the temp directory: {root:?}"+    );+}++/// THE MISSING PIN FOR `notCopiedYet`, WRITTEN OUT — mutation MF survived:+/// flipping this entry's `verified` from `null` to `true` breaks nothing in 413+/// tests. The human-facing `howToOpen` still says "NOT COPIED YET … copy it out+/// now", but `verified` is the field a programmatic caller branches on, and+/// `true` here would tell an agent that the one file whose ONLY copy sits in the+/// directory the next update deletes is a verified rescue. The code is correct;+/// this test passes. It exists so that stays true.+///+/// The state is built by hand AFTER the bridge starts (the migration runs at+/// start, and would otherwise copy the file and dissolve the state): a sweep in+/// the data root whose manifest names a file that exists only in the legacy+/// location.+#[test]+fn a_file_not_yet_copied_out_of_the_install_cache_is_never_reported_verified() {+    let lay = Layout::new("adv37-notcopied");+    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);++    let sweep = "20260917-101500";+    let mut body = vec![0u8; 64 * 1024];+    body[4..8].copy_from_slice(&[0x00, 0x00, 0x00, 0x04]);+    write(&lay.legacy().join("recovery").join(sweep).join("bracket.SLDPRT"), &body);+    write(+        &lay.data().join("recovery").join(sweep).join("manifest.json"),+        json!({+            "manifestVersion": 1,+            "files": [{+                "name": "bracket.SLDPRT", "title": "bracket.SLDPRT", "kind": "part",+                "recoveryBytes": body.len(), "verified": true, "preserved": true, "closed": true,+            }],+        })+        .to_string()+        .as_bytes(),+    );++    let r = b.call("swx_recover_list", json!({}));+    let entry = r["sweeps"]+        .as_array()+        .and_then(|a| a.iter().find(|s| s["location"] == json!("data") && s["sweep"] == json!(sweep)))+        .and_then(|s| s["files"].as_array())+        .and_then(|f| f.iter().find(|e| e["name"] == json!("bracket.SLDPRT")))+        .cloned()+        .unwrap_or(Value::Null);+    assert!(entry["notCopiedYet"] == json!(true), "precondition: the state was built: {}", s(&r));++    assert!(+        entry["verified"] != json!(true),+        "a file whose ONLY copy is in ab's install cache — deleted by the next update — is \+         reported verified:true. howToOpen may still warn, but verified is what a caller \+         branches on. Entry: {}",+        s(&entry)+    );+    assert!(+        entry["verified"].is_null(),+        "notCopiedYet is UNKNOWN, not a verdict either way: {}",+        s(&entry)+    );+}
tests/data_root.rs+551−53
@@ -38,7 +38,8 @@ fn a_visible_machine(tag: &str) -> TempDir {     d } -/// `%LOCALAPPDATA%`, with ab's layout under it.+/// `%LOCALAPPDATA%`, with ab's layout under it — and, beside it, the one+/// directory only this bridge writes to. struct Layout {     local: TempDir, }@@ -47,15 +48,25 @@ impl Layout {     fn new(tag: &str) -> Layout {         Layout { local: TempDir::new(tag) }     }+    /// The data root since 0.9.1: a SIBLING of `Adom Bridge\`.     fn data(&self) -> PathBuf {-        self.local.0.join("Adom Bridge").join("solidworks-data")+        self.local.0.join("adom-solidworks-bridge")+    }+    /// ab's own directory: its installer's and its uninstaller's.+    fn ab(&self) -> PathBuf {+        self.local.0.join("Adom Bridge")     }     fn cache(&self) -> PathBuf {-        self.local.0.join("Adom Bridge").join("bridges-cache")+        self.ab().join("bridges-cache")     }+    /// The first legacy root: 0.8.x's install cache.     fn legacy(&self) -> PathBuf {         self.cache().join("solidworks")     }+    /// The second legacy root: 0.9.0's data root, inside ab's directory.+    fn legacy_data(&self) -> PathBuf {+        self.ab().join("solidworks-data")+    } }  /// Unsaved work the sweep has to write somewhere: a pathless import and a saved@@ -199,9 +210,9 @@ fn recovery_is_written_to_the_data_root_never_into_the_install_cache() {     );     check!(         b,-        "swx_close_all_safely: and nothing at all was written into the install cache",-        !lay.cache().exists(),-        lay.cache().display().to_string()+        "swx_close_all_safely: and nothing at all was written into ab's directory",+        !lay.ab().exists(),+        lay.ab().display().to_string()     );      let l = b.call("swx_recover_list", json!({}));@@ -213,8 +224,13 @@ fn recovery_is_written_to_the_data_root_never_into_the_install_cache() {     );     check!(         b,-        "swx_recover_list: ...and names the legacy location it also reads",-        l["legacyRecoveryRoot"] == json!(lay.legacy().join("recovery").to_string_lossy()),+        "swx_recover_list: ...and names both legacy locations it also reads, oldest first",+        l["legacyRecoveryRoot"] == json!(lay.legacy().join("recovery").to_string_lossy())+            && l["legacyRecoveryRoots"]+                == json!([+                    {"path": lay.legacy().join("recovery").to_string_lossy(), "location": "legacyInstallCache"},+                    {"path": lay.legacy_data().join("recovery").to_string_lossy(), "location": "legacyDataRoot"},+                ]),         s(&l)     );     let sweep = l["sweeps"].as_array().and_then(|a| a.first()).cloned().unwrap_or(json!({}));@@ -309,12 +325,31 @@ fn what_an_older_bridge_left_in_the_install_cache_is_copied_out_verified_and_lef     let listed = |key: &str, file: &str| {         marker[key].as_array().map(|a| a.iter().any(|e| e["file"] == file)).unwrap_or(false)     };+    // SAME NAME, DIFFERENT BYTES: the legacy settings went BESIDE the existing+    // file — never over it, and never dropped either.+    let beside = marker["copied"]+        .as_array()+        .and_then(|a| a.iter().find(|e| e["file"] == "settings.json").cloned())+        .unwrap_or(json!({}));+    let beside_as = beside["copiedAs"].as_str().unwrap_or("").to_string();     check!(         b,-        "migration: the record says what was copied, what was already there, and that the originals stayed",+        "migration: a different file of the same name is copied BESIDE the existing one, not dropped",+        beside["outcome"] == "copiedBeside"+            && beside_as.starts_with("settings.json.legacy-")+            && same(&beside_as, br#"{"badge":false}"#),+        s(&marker)+    );+    check!(+        b,+        "migration: the record says what was copied, by root, size, mtime and digest, and that the originals stayed",         marker["originalsLeftInPlace"] == true+            && marker["markerVersion"] == 2             && listed("copied", "recovery/20260917-101500/bracket.SLDPRT")-            && listed("skipped", "settings.json"),+            && marker["copied"].as_array().map(|a| a.iter().all(|e| {+                e["legacyRoot"].is_string() && e["bytes"].is_u64() && e["modifiedMs"].is_u64()+                    && e["sha256"].as_str().map(|h| h.len() == 64).unwrap_or(false)+            })).unwrap_or(false),         s(&marker)     );     if unreadable {@@ -465,16 +500,23 @@ fn a_sweep_refuses_to_place_recovery_under_an_install_location() {     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"))),+    // ab's own directory, as `%LOCALAPPDATA%\Adom Bridge` — given only to the+    // case that needs it; everywhere else LOCALAPPDATA is empty, i.e. unset.+    let local = t.join("local");+    // (label, data root, install dir, ab's install cache, LOCALAPPDATA)+    let cases: Vec<(&str, String, String, String, String)> = vec![+        ("inside the install directory", p(&inst.join("data")), p(&inst), unrelated.clone(), String::new()),+        ("through a link to it", p(&t.join("link").join("data")), p(&inst), unrelated.clone(), String::new()),+        ("spelled with ..", format!("{}/../inst/data", p(&t.join("elsewhere"))), p(&inst), unrelated.clone(), String::new()),+        ("spelled in another case", p(&inst.join("data")).replace("/inst/", "/INST/"), p(&inst), unrelated.clone(), String::new()),+        ("AS the install directory", p(&t.join("eq")), p(&t.join("eq").join("recovery")), unrelated.clone(), String::new()),+        ("inside ab's install cache", p(&t.join("cache").join("data")), unrelated.clone(), p(&t.join("cache")), String::new()),+        // 0.9.0's OWN ROOT: inside the directory ab's installer and uninstaller own+        // (adversary pass 39). Not the cache, not the install directory — refused+        // for whose directory it is.+        ("inside ab's own directory", p(&local.join("Adom Bridge").join("solidworks-data")), unrelated.clone(), unrelated.clone(), p(&local)),     ];-    for (label, root, install, cache) in cases {+    for (label, root, install, cache, localappdata) 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() });@@ -484,6 +526,7 @@ fn a_sweep_refuses_to_place_recovery_under_an_install_location() {             ("SWX_SCRATCH_DIR", &root),             ("SWX_INSTALL_DIR", &install),             ("SWX_LEGACY_DIR", &cache),+            ("LOCALAPPDATA", &localappdata),             ("SWX_LAUNCH_FAKE_DIR", &machine.path()),         ]);         b.call("swx_start", json!({}));@@ -712,6 +755,39 @@ fn ownership_an_older_bridge_recorded_is_restored_by_the_first_start_after_the_m     ); } +/// A START THAT IS EXPECTED TO DIE, under the mock-only interruption fault.+/// Spawned directly — the harness rightly treats a bridge that never answers as a+/// failure — and never waited on forever: if the fault were not seen, the bridge+/// would serve until killed, and a suite that HANGS instead of failing hides the+/// very regression it exists to catch. Returns the exit code, or `None` if it+/// had to be killed.+fn a_start_that_should_die(local: &str) -> Option<i32> {+    let mut child = 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())+        .spawn()+        .expect("run the bridge");+    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);+    loop {+        if let Ok(Some(status)) = child.try_wait() {+            return status.code();+        }+        if std::time::Instant::now() > deadline {+            let _ = child.kill();+            let _ = child.wait();+            return None;+        }+        std::thread::sleep(std::time::Duration::from_millis(50));+    }+}+ /// 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@@ -737,19 +813,7 @@ fn a_start_that_dies_mid_copy_leaves_no_truncated_file_under_the_real_name() {      // 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");+    assert_eq!(a_start_that_should_die(&local), 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())@@ -770,18 +834,35 @@ fn a_start_that_dies_mid_copy_leaves_no_truncated_file_under_the_real_name() {     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.+    // TWO entries, and they agree: the partial bytes, which are not a rescue,+    // and the file the manifest names, which says ONLY A PARTIAL COPY of it+    // exists — not GONE, and not "moved or deleted by something else", because+    // nothing moved it: the copy never finished.+    let partial = files.iter().find(|f| f["name"] == "zeta.SLDPRT.migrating").cloned().unwrap_or(json!({}));+    let named = files.iter().find(|f| f["name"] == "zeta.SLDPRT").cloned().unwrap_or(json!({}));     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"),+        "swx_recover_list: the half-copied bytes are listed as a PARTIAL COPY, never as a rescue",+        files.len() == 2+            && partial["partialCopy"] == true+            && partial["copyOf"] == "zeta.SLDPRT"+            && partial["verified"] == false+            && partial["howToOpen"].as_str().unwrap_or("").contains("PARTIAL COPY"),         s(&l)     );+    let how = named["howToOpen"].as_str().unwrap_or("");+    check!(+        b,+        "swx_recover_list: ...and the file it was copying says ONLY A PARTIAL COPY exists — not GONE",+        named["present"] == false+            && named["partialCopyOnly"] == true+            && named["partialCopy"] == "zeta.SLDPRT.migrating"+            && named["verified"] == false+            && how.contains("ONLY A PARTIAL COPY EXISTS")+            && !how.contains("GONE")+            && !how.contains("moved or deleted by something else"),+        s(&named)+    );     drop(b);      // AND WHEN THE ORIGINAL IS STILL THERE, the next start simply copies it.@@ -791,19 +872,7 @@ fn a_start_that_dies_mid_copy_leaves_no_truncated_file_under_the_real_name() {     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");+    assert_eq!(a_start_that_should_die(&local), 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");@@ -820,3 +889,432 @@ fn a_start_that_dies_mid_copy_leaves_no_truncated_file_under_the_real_name() {         format!("{names:?}")     ); }++/// THE DATA ROOT IS A DIRECTORY ONLY THIS BRIDGE WRITES TO (adversary pass 39).+///+/// 0.9.0 moved the data root out of the directory ab re-extracts on every bridge+/// update, and into `%LOCALAPPDATA%\Adom Bridge\solidworks-data` — inside the+/// directory ab's INSTALLER owns ("installs to %LOCALAPPDATA%\Adom Bridge\", "the+/// uninstaller lives at %LOCALAPPDATA%\Adom Bridge\uninstall.exe", per ab's+/// docs). What an ab uninstall, a repair or "delete the Adom Bridge folder and+/// reinstall" does to the rest of that directory is undocumented and unmeasured.+/// So the root is a SIBLING, named for this bridge alone: not under any path+/// that contains `Adom Bridge`, and not under the install cache.+#[test]+fn the_data_root_is_a_directory_only_this_bridge_writes() {+    let lay = Layout::new("dataroot-sibling");+    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let l = b.call("swx_recover_list", json!({}));+    let recovery = PathBuf::from(l["recoveryRoot"].as_str().unwrap_or(""));+    let root = recovery.parent().map(|p| p.to_path_buf()).unwrap_or_default();+    check!(+        b,+        "swx_recover_list: the data root is %LOCALAPPDATA%\\adom-solidworks-bridge",+        root == lay.data(),+        format!("{} vs {}", root.display(), lay.data().display())+    );+    check!(+        b,+        "swx_recover_list: ...not under ANY path containing `Adom Bridge`",+        !root+            .components()+            .any(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase().contains("adom bridge")),+        root.display().to_string()+    );+    check!(+        b,+        "swx_recover_list: ...and not under ab's directory or its install cache",+        !root.starts_with(lay.ab()) && !root.starts_with(lay.cache()),+        root.display().to_string()+    );+}++/// TWO LEGACY ROOTS, OLDEST FIRST. 0.9.0's data root — `Adom Bridge\solidworks-data`+/// — is migrated like 0.8.x's install cache, after it. Rescues with distinct+/// names simply move; a file of the SAME name in both (the ledger, which 0.9.0+/// had itself copied from the cache and then appended to) keeps the first one+/// in place and the second BESIDE it, so neither the older nor the newer record+/// is lost or overwritten. 0.9.0's own migration record is not data and does not+/// come along.+#[test]+fn the_0_9_0_data_root_is_migrated_after_the_install_cache_and_nothing_is_lost() {+    let lay = Layout::new("dataroot-two-legacies");+    let cache_ledger = b"{\"at\":1788000000,\"action\":\"document_created\",\"detail\":{\"title\":\"FROM-0.8.9\"}}\n".to_vec();+    let mut data_ledger = cache_ledger.clone();+    data_ledger.extend_from_slice(b"{\"at\":1789000000,\"action\":\"close_all_safely\",\"detail\":{\"title\":\"FROM-0.9.0\"}}\n");+    write(&lay.legacy().join("ledger.jsonl"), &cache_ledger);+    write(&lay.legacy_data().join("ledger.jsonl"), &data_ledger);+    write(&lay.legacy_data().join("owned-docs.json"), br#"{"documents":[]}"#);+    write(&lay.legacy_data().join("migrated-from-legacy.json"), br#"{"copied":[],"why":"0.9.0's own record"}"#);+    let old = a_document(48 * 1024);+    let newer = a_document(56 * 1024);+    a_legacy_sweep(&lay.legacy().join("recovery"), "20260917-090000", "old.SLDPRT", &old);+    let newer_sweep = a_legacy_sweep(&lay.legacy_data().join("recovery"), "20260930-170000", "newer.SLDPRT", &newer);+    // THE SAME SWEEP AND NAME IN BOTH, WITH DIFFERENT BYTES: contrived, and the+    // case where "never over it, never dropped" has to hold for a rescue.+    let mut other = a_document(48 * 1024);+    other[100] = b'y';+    write(&lay.legacy_data().join("recovery").join("20260917-090000").join("old.SLDPRT"), &other);++    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let data = lay.data();+    let read = |rel: &str| std::fs::read(data.join(rel)).unwrap_or_default();+    check!(+        b,+        "migration: rescues from BOTH older locations are copied into the data root",+        read("recovery/20260917-090000/old.SLDPRT") == old+            && read("recovery/20260930-170000/newer.SLDPRT") == newer+            && read("owned-docs.json") == br#"{"documents":[]}"#.to_vec(),+        data.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 ledger_besides: Vec<String> = std::fs::read_dir(&data)+        .map(|d| {+            d.flatten()+                .map(|e| e.file_name().to_string_lossy().to_string())+                .filter(|n| n.starts_with("ledger.jsonl.legacy-"))+                .collect()+        })+        .unwrap_or_default();+    check!(+        b,+        "migration: the older ledger takes the name, the newer one is kept BESIDE it — neither is lost",+        read("ledger.jsonl").starts_with(&cache_ledger)+            && ledger_besides.len() == 1+            && read(&ledger_besides[0]) == data_ledger,+        format!("{ledger_besides:?}\n{}", s(&marker))+    );+    check!(+        b,+        "migration: the order is recorded, oldest first, and 0.9.0's own record is not copied as data",+        marker["from"]+            == json!([lay.legacy().to_string_lossy(), lay.legacy_data().to_string_lossy()])+            && marker["markerVersion"] == 2,+        s(&marker)+    );+    // A FILE ONLY THE SECOND LEGACY ROOT HOLDS, named by a record in the data+    // root (built after the start, so the migration cannot dissolve the state).+    write(&lay.legacy_data().join("recovery").join("20260930-170000").join("late.SLDPRT"), &a_document(40 * 1024));+    write(+        &data.join("recovery").join("20260930-170000").join("manifest.json"),+        json!({"manifestVersion": 1, "files": [+            {"name": "newer.SLDPRT", "title": "newer.SLDPRT", "kind": "part", "recoveryBytes": newer.len(), "verified": true, "preserved": true, "closed": true},+            {"name": "late.SLDPRT", "title": "late.SLDPRT", "kind": "part", "recoveryBytes": 40 * 1024, "verified": true, "preserved": true, "closed": true},+        ]}).to_string().as_bytes(),+    );+    let l = b.call("swx_recover_list", json!({}));+    let ours = sweeps_named(&l, "20260930-170000")+        .into_iter()+        .find(|x| x["location"] == "data")+        .cloned()+        .unwrap_or(json!({}));+    let late = ours["files"].as_array().and_then(|a| a.iter().find(|f| f["name"] == "late.SLDPRT").cloned()).unwrap_or(json!({}));+    check!(+        b,+        "swx_recover_list: a file only 0.9.0's location holds is NOT COPIED YET, pointing there",+        late["notCopiedYet"] == true+            && late["verified"].is_null()+            && late["legacyPath"]+                == json!(lay.legacy_data().join("recovery").join("20260930-170000").join("late.SLDPRT").to_string_lossy()),+        s(&ours)+    );+    let first = sweeps_named(&l, "20260917-090000")+        .into_iter()+        .find(|x| x["location"] == "data")+        .cloned()+        .unwrap_or(json!({}));+    let beside = first["files"]+        .as_array()+        .and_then(|a| a.iter().find(|f| f["copiedBeside"] == true).cloned())+        .unwrap_or(json!({}));+    check!(+        b,+        "migration + swx_recover_list: a same-named rescue with different bytes is kept BESIDE, and listed as such",+        read("recovery/20260917-090000/old.SLDPRT") == old+            && beside["besideOf"] == "old.SLDPRT"+            && std::fs::read(beside["path"].as_str().unwrap_or("")).ok() == Some(other.clone())+            && beside["howToOpen"].as_str().unwrap_or("").contains("A COPY KEPT BESIDE")+            && !first["files"].as_array().map(|a| a.iter().any(|f| f["name"].as_str().unwrap_or("").starts_with("manifest.json"))).unwrap_or(true),+        s(&first)+    );+    let theirs = sweeps_named(&l, "20260930-170000")+        .into_iter()+        .find(|x| x["location"] == "legacyDataRoot")+        .cloned()+        .unwrap_or(json!({}));+    // `late.SLDPRT` has no copy, so this sweep is still the only copy of it.+    check!(+        b,+        "swx_recover_list: a rescue in 0.9.0's location is listed, at risk (ab's directory), honest about its copy",+        theirs["atRisk"] == true+            && theirs["atRiskFromUpdate"] == false+            && theirs["copiedTo"].is_null()+            && theirs["warning"].as_str().unwrap_or("").contains("ab's own directory")+            && theirs["directory"] == json!(newer_sweep.to_string_lossy()),+        s(&theirs)+    );+    let older = sweeps_named(&l, "20260917-090000")+        .into_iter()+        .find(|x| x["location"] == "legacyInstallCache")+        .cloned()+        .unwrap_or(json!({}));+    check!(+        b,+        "swx_recover_list: a fully copied legacy sweep points at its copy",+        older["copiedTo"] == json!(data.join("recovery").join("20260917-090000").to_string_lossy()),+        s(&older)+    );+}++/// A LEGACY FILE THAT CHANGES AFTER ITS COPY IS COPIED AGAIN — BESIDE THE FIRST+/// COPY, NEVER OVER IT — AND SAID TO HAVE CHANGED (adversary pass 39, sev-2).+///+/// 0.9.0 settled a migrated file by its NAME, so the two files that change by+/// nature — the append-only ledger and the rewritten ownership registry — were+/// never looked at again: a rollback to an older bridge appends to the OLD+/// ledger, and the next update deletes it. Now each copy is recorded by (legacy+/// root, path, size, mtime, sha256 of the copy), and a later start that finds the+/// legacy file different copies it again as `<name>.legacy-<stamp>` and reports+/// it — in the migration record, in the ledger, and in `swx_recover_list`.+#[test]+fn a_legacy_file_that_changes_after_its_copy_is_copied_again_beside_it() {+    let lay = Layout::new("dataroot-changed");+    let legacy_ledger = lay.legacy().join("ledger.jsonl");+    let legacy_owned = lay.legacy().join("owned-docs.json");+    let before = b"{\"at\":1789000000,\"action\":\"document_created\",\"detail\":{\"title\":\"BEFORE\"}}\n".to_vec();+    write(&legacy_ledger, &before);+    write(&legacy_owned, br#"{"documents":[]}"#);+    let legacy_settings = lay.legacy().join("settings.json");+    write(&legacy_settings, br#"{"badge":false}"#);+    write(&lay.legacy().join("timings.json"), br#"{"swx_docs":{"count":3,"x":1}}"#);+    let local = lay.local.path();+    let env = [("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())];+    let data = lay.data();+    let besides = |prefix: &str| -> Vec<String> {+        let mut v: Vec<String> = std::fs::read_dir(&data)+            .map(|d| {+                d.flatten()+                    .map(|e| e.file_name().to_string_lossy().to_string())+                    .filter(|n| n.starts_with(prefix))+                    .collect()+            })+            .unwrap_or_default();+        v.sort();+        v+    };+    let marker = || -> Value {+        serde_json::from_str(&std::fs::read_to_string(data.join("migrated-from-legacy.json")).unwrap_or_default())+            .unwrap_or(json!({}))+    };+    drop(Bridge::start_with(&env));+    let first_copy = std::fs::read(data.join("ledger.jsonl")).unwrap_or_default();+    assert!(first_copy.starts_with(&before), "precondition: the first start copied the legacy ledger");++    // A ROLLBACK: the older bridge appends to its ledger and rewrites its registry.+    let mut rolled = before.clone();+    rolled.extend_from_slice(b"{\"at\":1790000000,\"action\":\"document_closed\",\"detail\":{\"title\":\"DURING-ROLLBACK\"}}\n");+    write(&legacy_ledger, &rolled);+    write(&legacy_owned, br#"{"documents":[{"title":"Part9","session":4242}]}"#);+    // A DIFFERENT SIZE WITH THE SAME MTIME: a restore that keeps timestamps, or a+    // filesystem whose mtime is coarser than the two writes (FAT32 keeps two+    // seconds). Only the size can tell.+    let legacy_timings = lay.legacy().join("timings.json");+    let timings_mtime = std::fs::metadata(&legacy_timings).unwrap().modified().unwrap();+    write(&legacy_timings, br#"{"swx_docs":{"count":4}}"#);+    std::fs::File::options()+        .write(true)+        .open(&legacy_timings)+        .unwrap()+        .set_modified(timings_mtime)+        .unwrap();+    // SAME SIZE, DIFFERENT CONTENT, LATER MTIME: only the mtime can tell.+    let settings_was = std::fs::metadata(&legacy_settings).unwrap().modified().unwrap();+    write(&legacy_settings, br#"{"badge":"no!"}"#);+    std::fs::File::options()+        .write(true)+        .open(&legacy_settings)+        .unwrap()+        .set_modified(settings_was + std::time::Duration::from_secs(60))+        .unwrap();++    let b = Bridge::start_with(&env);+    let ledger_besides = besides("ledger.jsonl.legacy-");+    let owned_besides = besides("owned-docs.json.legacy-");+    check!(+        b,+        "migration: the CHANGED legacy ledger is copied again, beside the first copy",+        ledger_besides.len() == 1 && std::fs::read(data.join(&ledger_besides[0])).ok() == Some(rolled.clone()),+        format!("{ledger_besides:?}")+    );+    check!(+        b,+        "migration: ...and the first copy is never overwritten",+        std::fs::read(data.join("ledger.jsonl")).unwrap_or_default().starts_with(&before)+            && !String::from_utf8_lossy(&std::fs::read(data.join("ledger.jsonl")).unwrap_or_default())+                .contains("DURING-ROLLBACK"),+        String::new()+    );+    let timings_besides = besides("timings.json.legacy-");+    check!(+        b,+        "migration: a size change with the mtime put back is caught by its size and kept beside",+        timings_besides.len() == 1+            && std::fs::read(data.join(&timings_besides[0])).ok() == Some(br#"{"swx_docs":{"count":4}}"#.to_vec()),+        format!("{timings_besides:?}")+    );+    let settings_besides = besides("settings.json.legacy-");+    check!(+        b,+        "migration: a same-size rewrite is caught by its mtime and kept beside too",+        settings_besides.len() == 1+            && std::fs::read(data.join(&settings_besides[0])).ok() == Some(br#"{"badge":"no!"}"#.to_vec()),+        format!("{settings_besides:?}")+    );+    check!(+        b,+        "migration: the rewritten ownership registry is kept beside too",+        owned_besides.len() == 1+            && std::fs::read(data.join(&owned_besides[0])).ok()+                == Some(br#"{"documents":[{"title":"Part9","session":4242}]}"#.to_vec()),+        format!("{owned_besides:?}")+    );+    let m = marker();+    let changed = m["changedSinceCopy"].as_array().cloned().unwrap_or_default();+    let ledger_change = changed.iter().find(|e| e["file"] == "ledger.jsonl").cloned().unwrap_or(json!({}));+    check!(+        b,+        "migration: the record says it CHANGED SINCE ITS COPY — what it was, what it is, where it went",+        ledger_change["was"]["bytes"] == before.len()+            && ledger_change["now"]["bytes"] == rolled.len()+            && ledger_change["now"]["sha256"].as_str().map(|h| h.len() == 64).unwrap_or(false)+            && ledger_change["copiedAs"] == json!(ledger_besides.first().cloned().unwrap_or_default())+            && changed.iter().any(|e| e["file"] == "owned-docs.json"),+        s(&m)+    );+    check!(+        b,+        "migration: ...and the ledger says so in a line of its own",+        ledger_count(&data, "legacy_file_changed_since_copy") == 1,+        std::fs::read_to_string(data.join("ledger.jsonl")).unwrap_or_default()+    );+    let l = b.call("swx_recover_list", json!({}));+    check!(+        b,+        "swx_recover_list: reports what changed since its copy",+        l["migration"]["changedSinceCopy"].as_array().map(|a| a.len()) == Some(4),+        s(&l["migration"])+    );+    drop(b);++    // NOTHING CHANGED: nothing is copied again.+    drop(Bridge::start_with(&env));+    let b = Bridge::start_with(&env);+    check!(+        b,+        "migration: an unchanged legacy file is not copied again",+        besides("ledger.jsonl.legacy-").len() == 1 && marker()["changedSinceCopy"].as_array().map(|a| a.len()) == Some(4),+        s(&marker())+    );+    drop(b);++    // IT CHANGES AGAIN: a second copy, under another name — the first beside copy+    // is never overwritten either.+    let mut again = rolled.clone();+    again.extend_from_slice(b"{\"at\":1791000000,\"action\":\"document_closed\",\"detail\":{\"title\":\"AGAIN\"}}\n");+    write(&legacy_ledger, &again);+    let b = Bridge::start_with(&env);+    let ledger_besides_now = besides("ledger.jsonl.legacy-");+    check!(+        b,+        "migration: a second change makes a second copy, and the first copy beside stays as it was",+        ledger_besides_now.len() == 2+            && std::fs::read(data.join(&ledger_besides[0])).ok() == Some(rolled.clone())+            && ledger_besides_now+                .iter()+                .any(|n| std::fs::read(data.join(n)).ok() == Some(again.clone())),+        format!("{ledger_besides_now:?}")+    );+}++/// NOT WHERE THE OS CLEANS (adversary pass 39, sev-3).+///+/// With neither `LOCALAPPDATA` nor `USERPROFILE`, the only location left is the+/// temp directory — which Storage Sense and Disk Cleanup empty by design. The+/// root may still point there (the bridge has to keep its notes somewhere), but+/// NO SWEEP writes the only copy of unsaved work there: it refuses with+/// `data_root_is_temp` before writing or closing anything, and the start-up log+/// says why. With `USERPROFILE`, the profile is used instead — before temp, always.+#[test]+fn with_no_profile_location_every_sweep_refuses_rather_than_write_to_temp() {+    let t = TempDir::new("dataroot-temp");+    let child_tmp = t.join("child-tmp");+    std::fs::create_dir_all(&child_tmp).unwrap();+    let c = child_tmp.to_string_lossy().to_string();+    let temp_root = child_tmp.join("adom-solidworks-bridge");+    a_session_with_unsaved_work(&temp_root);+    let machine = a_visible_machine("dataroot-temp-machine");+    let b = Bridge::start_with(&[+        ("SWX_SCRATCH_DIR", ""),+        ("LOCALAPPDATA", ""),+        ("USERPROFILE", ""),+        ("TMPDIR", &c),+        ("TMP", &c),+        ("TEMP", &c),+        ("SWX_LAUNCH_FAKE_DIR", &machine.path()),+    ]);+    check!(+        b,+        "start-up: a data root in the temp directory is announced, with what it means",+        b.log().contains("WARNING data root") && b.log().contains("data_root_is_temp"),+        b.log()+    );+    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,+        "swx_close_all_safely: REFUSES to put the only copy of unsaved work in the temp directory",+        r["success"] == false && r["errorCode"] == "data_root_is_temp",+        s(&r)+    );+    let after = b.call("swx_docs", json!({}));+    check!(+        b,+        "swx_close_all_safely: ...closing nothing and writing nothing",+        before["count"].as_u64().is_some()+            && before["count"] == after["count"]+            && !temp_root.join("recovery").exists(),+        format!("before {} after {}", s(&before), s(&after))+    );+    drop(b);++    // WITH A PROFILE: the profile, not temp — and the sweep runs.+    let profile = t.join("profile");+    let profile_root = profile.join("adom-solidworks-bridge");+    a_session_with_unsaved_work(&profile_root);+    let pr = profile.to_string_lossy().to_string();+    let b = Bridge::start_with(&[+        ("SWX_SCRATCH_DIR", ""),+        ("LOCALAPPDATA", ""),+        ("USERPROFILE", &pr),+        ("TMPDIR", &c),+        ("TMP", &c),+        ("TEMP", &c),+        ("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: with USERPROFILE the root is the profile's, and the sweep runs there",+        r["success"] == true+            && PathBuf::from(r["recoveryDir"].as_str().unwrap_or("")).starts_with(profile_root.join("recovery"))+            && !b.log().contains("WARNING data root"),+        s(&r)+    );+}
tests/wiring.rs+4−2
@@ -124,8 +124,10 @@ const GUARDS: &[(&str, &str)] = &[      "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"),+    ("legacy_roots",+     "the listing and the migration still read every location older bridges wrote to"),+    ("root_is_temp_fallback",+     "the only copy of unsaved work never goes to a directory the OS cleans on a schedule"),     ("closed_in_place_entry",      "a document closed at its own path is listed, with whether that file is still untouched"), ];