← Commit history

0.9.2 (707f990): two versions of the ledger or the ownership registry are merged, not picked; small legacy files are hashed on a size+mtime match; an unwritable recovery folder is refused once before the first save.

Drew Owens ·1670e34057 ·20d ago ·parent 9b1e3be
14 files changed +1097−93
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.1+0.9.2
Cargo.lock+1−1
@@ -107,7 +107,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.1"+version = "0.9.2" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.1"+version = "0.9.2" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+33−1
@@ -458,7 +458,39 @@ not.  ## Roadmap -- v0.9.1 (this): **the data root moves out of ab's directory altogether — and a+- v0.9.2 (this): **two versions of the ledger or the ownership registry are+  MERGED, not picked; a same-size, same-mtime rewrite is caught by its hash; and+  a recovery folder that cannot be made is refused once, before the first save**+  (adversary pass 40; sev-1 count zero, all seven of its mutants caught).++  0.9.1 settled a clash between the two older locations by giving the real name+  to the OLDER ROOT's copy and putting the other beside it. On exactly Drew's+  timeline that is backwards — 0.8.9 re-created the cache's ledger after 0.9.0+  had copied it and kept appending — so the live `ledger.jsonl` held only the+  cache era and the 0.9.0 era sat in a side file `swx_action_ledger` never+  reads. "Newest mtime wins" fails the mirror case, where the two ledgers are+  DIVERGENT. So nothing is picked: the live ledger becomes the union of both+  (each exact line once, ordered by `at`), the live ownership registry the union+  of both claim lists (restore re-validates every claim against the live+  session), BOTH versions are kept beside the merged file, and the merge is+  recorded in `migrated-from-legacy.json` (`merged`), in the ledger+  (`legacy_files_merged`) and in `swx_recover_list`. The same merge applies when+  a legacy ledger or registry changes after its copy, so a rollback's entries+  reach the live ledger too. A version that cannot be merged is kept beside and+  reported (`mergeFailed`); every other file keeps the beside rule.++  Change detection hashed only the COPY. A restore that puts timestamps back —+  File History, `robocopy /COPY:DAT`, a sync client — can return different bytes+  of the SAME length under the SAME mtime, and equal length is normal for the+  ownership registry. Now a legacy file up to 16 MiB whose size and mtime match+  is hashed and compared with the digest recorded when it was copied; larger+  files keep size and mtime, and every record says which (`comparedBy`).++  And `recovery_dir()` dropped `create_dir_all`'s error and returned the path+  anyway, so a sweep on a blocked recovery location made one doomed `SaveAs3`+  per document. It now refuses once, before the first save, with+  `recovery_dir_unwritable` and the cause. 433 tests.+- v0.9.1: **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\` —
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.1",+  "version": "0.9.2",   "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",
docs/ECOSYSTEM-TABLE.md+1−1
@@ -112,7 +112,7 @@ 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 — 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 | ✗ | ✗ | ✗ | ✗ | ✗ |+| 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 47 | **Interconnect-aware import: refuses honestly, with the reason and the setting** | ✗ | **✓ MEASURED + shipped 0.8.2** — on SOLIDWORKS Connected 2026 with `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic neutral-file import route at all: LoadFile4 returns nothing (`errors=1`) both with `Nothing` and with a real `GetImportFileData` object, LoadFile2 returns false, OpenDoc6 answers `swFileRequiresRepairError` (2097152) across four option values and `swInvalidFileTypeError` (1024) for docType 0. So `swx_import` makes ONE LoadFile4 attempt (a future edition that accepts it simply works) and then fails with `interconnect_blocks_import`, naming the exact setting — Tools > Options > System Options > Import > Enable 3D Interconnect — the measurement date, and the fact that **this bridge does not change user settings, not even temporarily**. No MCP server reads that toggle at all: they report a translator failure, which sends the reader hunting a missing translator that is not the problem | ✗ | ✗ | ✗ | ✗ | ✗ | | 46 | **3D Interconnect is understood, obeyed and reported** (the user's own setting decides how a neutral file comes in; the bridge reads it and never writes it) | ✗ | **✓ LIVE-MEASURED 2026-09-17, routed in 0.8.0** — `GetUserPreferenceToggle(691)` = TRUE on the install, so a STEP is not translated at all: `LoadFile4` executes and returns nothing with `errors=1`, and SOLIDWORKS instead OPENS the file with `OpenDoc6(path, swDocPART, …)` as a part holding a LINKED 3D Interconnect feature. `swx_import` reads the toggle, routes accordingly, and reports `interconnect` / `linked` / `sourcePath` so a caller knows the part is not self-contained. The preference is NEVER written — not even flip-and-restore, because a crash between the halves would leave the user's CAD configured differently than they left it — and `dissolve:true` is REFUSED until the link-breaking call is measured on this edition. Import-diagnostics auto-run (toggle 291) is also TRUE, so the modal dialog it raises is classified, reported as `stoppedByDialog`, and never answered. Toggle ids read from the installed `SolidWorks.Interop.swconst.dll` | ✗ | ✗ | ✗ | ✗ | ✗ | | 45 | **Closes its own documents without asking — and loses nothing doing it** (preserve-then-close: every open document written to a recovery folder and verified there BEFORE anything closes) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (0.7.7)** — `swx_shutdown {reason}` / `swx_close_all_safely {reason}` save every open document into `%LOCALAPPDATA%\adom-solidworks-bridge\recovery\<timestamp>\` (0.9.0: `Adom Bridge\solidworks-data\…`, inside the directory ab's installer owns; before that `…\bridges-cache\solidworks\recovery\…`, inside ab's install cache, which an update empties: row 49), verify it, close parents before children, then quit; no write opt-in, no per-document confirmation (Drew, 2026-09-11: *"I despise every time you ask for help closing"*). The authorization is to CLOSE, not to lose anything, so the consent question is replaced by checkable rules: the sweep REFUSES TO START when the recovery volume cannot hold the session it is about to write (`insufficient_headroom` with free/needed/shortfall, nothing written, nothing closed), re-checked before every save, with `headroomBytesBefore` and `estimatedWriteBytes` always reported — because a recovery file can be the RIGHT document CUT SHORT and no check on the file can tell (the marker is at offset 4, so every prefix carries it); the recovery file is verified STRUCTURALLY — it must be a recognised SOLIDWORKS container AND at least as big as the smallest real document of its KIND (part 24 KB, assembly 80 KB, drawing 256 KB — one floor for all three would have to be the lowest of them, letting an assembly stop at a third of its size), with the KIND carried from the COM read to the verdict and NEVER re-derived from the recovery file's name, because that name is chosen from the kind and so cannot check it; an unreadable document type falls back to the document's OWN PATH and then its title (evidence about the document, not names this sweep chose) and only then to UNKNOWN, which takes a neutral `.sldrecover` name and the strictest floor — and if SOLIDWORKS refuses to write that name the file is re-saved as `.SLDPRT` with the fallback RECORDED and the unknown-kind verification unchanged; `swx_recover_list` re-runs the verdict on every file it lists and reports `verified` with the reason — a file the sweep refused is listed (never deleted: those bytes are the only ones there are) but never called a rescue, never offered as openable, and its entry says the document was left OPEN — and gives every genuine rescue its own `howToOpen`, because SOLIDWORKS will not open a `.sldrecover` file until it is renamed, published as `recoverySignatureOk` + `recoveryBytes`, and it fails CLOSED (`exists()` passes a zero-byte file and any byte floor passes a truncated one; both are what a full disk leaves). The container marker was MEASURED on 166 real documents from three hardware repositories, because the signature every reference names for a SOLIDWORKS document (OLE compound file) appears in NONE of them — verifying that, as pass 23 specified, would have refused to close every document on a modern install; **FIRST AUTONOMOUS CLOSE, live 2026-09-17**: `swx_close_all_safely {userAuthorized, batch:1}` on a bridge-launched session took 4 documents (3 same-titled pathless import temps + a throwaway part) to `recovery\20260917-150646\` under unique names (25,834 / 26,031 / 25,773 / 26,047 bytes, `recoverySignatureOk:true`, `namingFallback:null`), closed each BY PATH, count 4 → 0, `done:true`, `stoppedBy:null`, headroom 1.59 TB — nobody was asked to close anything. A document that could not be written is left OPEN and named in `remaining`; the user's saved-then-edited file is copied to recovery under a new name and their original is asserted byte-for-byte unchanged; `SaveAs3` uses `swSaveAsOptions_Silent` ALONE so the document RE-POINTS at the recovery file and the close key is read from the document after the save; a dialog stops the sweep and is never answered, with the census taken before EVERY save and EVERY close (a save can raise a prompt of its own, which a per-batch census could not see until the closes had happened); and an UNREADABLE window census is its own outcome — saves proceed, nothing closes, `stoppedBy:"dialogCensusUnknown"`. Ordering is proven from a journal, not from the end state. Every rule is mutation-verified (disable it and exactly one test fails). **Ownership measured live 2026-09-30, both halves**: a pathless `Part2` preserved (25,959 bytes, verified, listed from the manifest) and Drew's saved `R_0603_1608Metric_1.step-1.sldprt` closed with its original untouched (same 32,311 bytes, same mtime). Since 0.9.0 such a document is RECORDED as `closedInPlace` with its original's size and mtime, and `swx_recover_list` says "closed at its own path, untouched" — or that it has changed since, or is gone — by measuring the file, not by assuming it | ✗ | ✗ | ✗ | ✗ | ✗ |
docs/LAUNCH-RESILIENCE.md+12
@@ -980,6 +980,18 @@ 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. +**The two-root clash, settled by merging (0.9.2, adversary pass 40).** When both+legacy roots hold a ledger or an ownership registry, the two are versions of one+history, and on Drew's timeline neither is the whole of it: the 2026-09-18+update wiped the cache, 0.8.9 re-created its ledger there, 0.9.0 copied it into+`solidworks-data` and kept appending — and a cache re-created again after that+would be newer by mtime and stale in content. 0.9.1's rule (the older ROOT keeps+the real name) put the stale copy in the live ledger; "newest mtime wins" fails+the mirror case. So 0.9.2 picks neither: the live ledger is the union of both+(each line once, in `at` order), the live registry the union of both claim lists,+both originals stay beside it, and the merge is in the migration record and the+ledger.+ **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.
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.1",+  "version": "0.9.2",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
references/closing-and-recovery.md+14−4
@@ -83,12 +83,22 @@ Read this before closing any document, quitting SOLIDWORKS, draining leaked impo   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:**+- **The ledger and the ownership registry are MERGED, not picked.** When two+  versions exist — one in each older location, or one that changed after it was+  copied — the live `ledger.jsonl` holds every line of both (once each, in `at`+  order) and the live `owned-docs.json` every claim of both; both versions are+  kept beside it and `migration.merged` says where. So `swx_action_ledger`+  shows every era of history, including a rollback's. A version that could not+  be merged is under `migration.mergeFailed`, kept beside, never dropped.+- **Three refusals that wrote and closed NOTHING, and are not save 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+  directory, ab's own directory, or an older bridge's location),   `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.+  root fell back to the OS temp directory, which Windows empties on a schedule),+  and `recovery_dir_unwritable` (the recovery folder could not be created —+  permissions, a quota, a file where the folder must go; the reply names the+  cause once, before any document was saved). Say what the reply says; do not+  retry until the cause is fixed. - **`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.
src/scratch.rs+344−31
@@ -38,11 +38,27 @@ //! 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.+//! path, size, mtime, sha256). 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`; "changed" means a different size or+//! mtime, or — for a file up to 16 MiB whose size and mtime match — a different+//! sha256, because a restore can put different bytes of the same length back+//! under the same mtime (adversary pass 40). `swx_recover_list` reads the root+//! and every legacy root.+//!+//! TWO VERSIONS OF THE LEDGER OR THE REGISTRY ARE MERGED, NOT PICKED (adversary+//! pass 40). Same-named files in the two legacy roots, or a legacy file that+//! changed after its copy, are two versions of one thing — and for an append-only+//! ledger and an ownership registry both versions are partly right. "The older+//! root keeps the name" handed the live ledger the STALE copy on exactly Drew's+//! timeline (the cache re-created by 0.8.9 after 0.9.0 had copied it and kept+//! appending), and "newest mtime wins" fails the mirror case. So the live+//! `ledger.jsonl` becomes the union of both — each exact line once, ordered by+//! `at` — and the live `owned-docs.json` the union of both claim lists (restore+//! re-validates every claim against the live session). Both versions are kept+//! beside the merged file, and the merge is recorded in the migration record and+//! the ledger. Any other file keeps the beside rule. //! //! `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@@ -394,6 +410,137 @@ fn copy_verified(from: &Path, to: &Path, dest: &Path) -> Result<String, String>     } } +/// A file this size or smaller is HASHED on every start when its size and mtime+/// match its record, because a restore can put different bytes of the same+/// length back under the same mtime (adversary pass 40). Larger files are+/// compared by size and mtime only, and their records say so (`comparedBy`).+pub const MIGRATION_HASH_CAP: u64 = 16 * 1024 * 1024;++/// The files whose versions are MERGED rather than picked between. An+/// append-only ledger and an ownership registry are the two files whose+/// versions in two places are both partly right — two histories, two sets of+/// claims — so a rule that keeps one copy under the real name drops the other's+/// half from the live state (adversary pass 40: "the older root keeps the name"+/// gave the live ledger the STALE copy on Drew's timeline).+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Merge {+    Ledger,+    Registry,+}++pub fn merge_kind(rel: &Path) -> Option<Merge> {+    match rel.to_str()? {+        "ledger.jsonl" => Some(Merge::Ledger),+        "owned-docs.json" => Some(Merge::Registry),+        _ => None,+    }+}++/// THE LEDGER, MERGED: every line of both, each exact line once, ordered by `at`.+/// The sort is stable, and a line with no readable `at` takes the `at` of the+/// line before it in its own file, so it stays beside the entries it was written+/// among. Returns the merged text and its line count.+pub fn merge_ledger_lines(live: &str, incoming: &str) -> (String, usize) {+    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();+    let mut lines: Vec<(u64, String)> = Vec::new();+    for text in [live, incoming] {+        let mut last_at = 0u64;+        for line in text.lines() {+            let line = line.trim_end_matches('\r');+            if line.trim().is_empty() {+                continue;+            }+            let at = serde_json::from_str::<serde_json::Value>(line)+                .ok()+                .and_then(|v| v.get("at").and_then(|a| a.as_u64()))+                .unwrap_or(last_at);+            last_at = at;+            if seen.insert(line.to_string()) {+                lines.push((at, line.to_string()));+            }+        }+    }+    lines.sort_by_key(|(at, _)| *at);+    let n = lines.len();+    let mut out = lines.into_iter().map(|(_, l)| l).collect::<Vec<_>>().join("\n");+    if n > 0 {+        out.push('\n');+    }+    (out, n)+}++/// THE REGISTRY, MERGED: every claim in either, each once. A claim is+/// re-validated against the live SOLIDWORKS session when the registry is+/// restored (`owned::restore`), so a union can only ever PROPOSE ownership, never+/// grant it. `None` when either file is not a registry this bridge wrote.+pub fn merge_registry(live: &str, incoming: &str) -> Option<(String, usize)> {+    let docs = |t: &str| -> Option<Vec<serde_json::Value>> {+        serde_json::from_str::<serde_json::Value>(t).ok()?.get("documents")?.as_array().cloned()+    };+    let (a, b) = (docs(live)?, docs(incoming)?);+    let mut out: Vec<serde_json::Value> = Vec::new();+    for d in a.into_iter().chain(b) {+        if !out.contains(&d) {+            out.push(d);+        }+    }+    let n = out.len();+    Some((serde_json::json!({"documents": out}).to_string(), n))+}++/// MERGE a legacy version INTO the live file. Both originals are kept BESIDE the+/// merged file, verified, BEFORE anything is rewritten: the live file as it was,+/// and the incoming version. The merged text holds every line (or claim) of+/// both, so the rewrite loses nothing; it is written to a temporary name and+/// renamed into place — never through `write_atomic`, whose `.bak` would+/// overwrite a `.bak` the migration may have just copied. Returns the record of+/// the merge; on any error nothing has been rewritten.+fn merge_into(+    kind: Merge,+    from: &Path,+    to: &Path,+    dest: &Path,+    rel: &Path,+    stamp: &str,+) -> Result<serde_json::Value, String> {+    use serde_json::json;+    let live = std::fs::read_to_string(to).map_err(|e| format!("the live file could not be read: {e}"))?;+    let incoming =+        std::fs::read_to_string(from).map_err(|e| format!("the legacy file could not be read: {e}"))?;+    let (text, entries) = match kind {+        Merge::Ledger => merge_ledger_lines(&live, &incoming),+        Merge::Registry => merge_registry(&live, &incoming).ok_or_else(|| {+            "one of the two is not a registry this bridge wrote (JSON with a `documents` list)".to_string()+        })?,+    };+    let rel_of = |p: &Path| p.strip_prefix(dest).unwrap_or(p).to_string_lossy().to_string();+    let before = beside_name(dest, rel, stamp);+    let before_sha = copy_verified(to, &before, dest)?;+    let incoming_copy = beside_name(dest, rel, stamp);+    let incoming_sha = copy_verified(from, &incoming_copy, dest)?;+    let tmp = to.with_file_name(format!(+        "{}.merging",+        to.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()+    ));+    std::fs::write(&tmp, text.as_bytes())+        .and_then(|_| std::fs::rename(&tmp, to))+        .map_err(|e| {+            let _ = std::fs::remove_file(&tmp);+            format!("the merged file could not be written: {e}")+        })?;+    Ok(json!({+        "kind": match kind { Merge::Ledger => "ledger", Merge::Registry => "registry" },+        "into": rel_of(to),+        "liveBefore": {"copiedAs": rel_of(&before), "sha256": before_sha, "bytes": live.len()},+        "incoming": {"copiedAs": rel_of(&incoming_copy), "sha256": incoming_sha, "bytes": incoming.len()},+        "result": {+            "bytes": text.len(),+            "sha256": crate::sha256::hex_of(text.as_bytes()),+            "entries": entries,+        },+    }))+}+ /// COPY WHAT THE LEGACY ROOTS HOLD INTO THE DATA ROOT. /// /// Legacy roots are taken OLDEST FIRST, files in a fixed order. Every copy goes@@ -487,9 +634,23 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {      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 (mut copied, mut skipped, mut failed, mut changed, mut merged) = (+        Vec::<Value>::new(),+        Vec::<Value>::new(),+        Vec::<Value>::new(),+        Vec::<Value>::new(),+        Vec::<Value>::new(),+    );+    let mut merged_history: Vec<Value> = list("merged");+    let mut merge_failed: Vec<Value> = Vec::new();     let rel_of = |p: &Path| p.strip_prefix(&dest).unwrap_or(p).to_string_lossy().to_string();+    let compared_by = |bytes: u64| -> &'static str {+        if bytes <= MIGRATION_HASH_CAP {+            "size+mtime+sha256"+        } else {+            "size+mtime (over the 16 MiB hash cap)"+        }+    };     for (legacy, rel) in candidates {         let from = legacy.join(&rel);         let name = rel.to_string_lossy().to_string();@@ -504,44 +665,107 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {             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         });+        let to = dest.join(&rel);         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)+                let meta_differs = matches!(was_bytes, Some(b) if b != bytes)                     || matches!((was_mtime, mtime), (Some(a), Some(b)) if a != b);+                // THE SAME SIZE AND MTIME IS NOT THE SAME FILE (adversary pass 40).+                // A restore that puts timestamps back — File History, robocopy+                // /COPY:DAT, a sync client — can return different bytes of the+                // same length, and equal length is NORMAL for the ownership+                // registry (`Part<N>-<13-digit epoch>`). So a small file that+                // matches on both is HASHED and compared with the digest+                // recorded when it was copied. A file over the cap keeps size and+                // mtime, and its record says so (`comparedBy`).+                let differs = meta_differs+                    || (bytes <= MIGRATION_HASH_CAP && {+                        let was = records[i]+                            .get("legacySha256")+                            .or_else(|| records[i].get("sha256"))+                            .and_then(|v| v.as_str())+                            .map(|v| v.to_string());+                        match (was, crate::sha256::hex_of_file(&from)) {+                            (Some(w), Some(now)) => w != now,+                            // Nothing to compare against, or unreadable now: the+                            // record stays as it was, as before this check.+                            _ => false,+                        }+                    });                 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 was = json!({+                    "bytes": was_bytes,+                    "modifiedMs": was_mtime,+                    "sha256": records[i].get("legacySha256").or_else(|| records[i].get("sha256")).cloned().unwrap_or(Value::Null),+                    "copiedAs": records[i].get("copiedAs").cloned().unwrap_or(json!(name)),+                });+                // CHANGED SINCE ITS COPY. A ledger or a registry is MERGED into+                // the live file — both versions' copies kept beside it — so the+                // live file holds every history (adversary pass 40); anything else+                // is copied again, beside, never over.+                let merge = match merge_kind(&rel).filter(|_| to.is_file()) {+                    Some(kind) => match merge_into(kind, &from, &to, &dest, &rel, &stamp) {+                        Ok(m) => Some(m),+                        // A version that cannot be merged is still never dropped:+                        // it goes beside, below, and the failure is reported.+                        Err(why) => {+                            merge_failed.push(json!({+                                "legacyRoot": lroot, "file": name,+                                "why": format!("could not be merged, so it was copied beside instead: {why}"),+                            }));+                            None+                        }+                    },+                    None => None,+                };+                let outcome = match merge {+                    Some(m) => Ok((m, true)),+                    None => {+                        let beside = beside_name(&dest, &rel, &stamp);+                        copy_verified(&from, &beside, &dest)+                            .map(|sha| (json!({"incoming": {"copiedAs": rel_of(&beside), "sha256": sha}}), false))+                    }+                };+                match outcome {+                    Ok((m, was_merged)) => {+                        let incoming_as = m["incoming"]["copiedAs"].clone();+                        let incoming_sha = m["incoming"]["sha256"].clone();                         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),+                            "was": was,+                            "now": {"bytes": bytes, "modifiedMs": mtime, "sha256": incoming_sha},+                            "copiedAs": incoming_as,+                            "mergedInto": if was_merged { json!(name) } else { Value::Null },                             "atMs": now_ms,                         });                         changed.push(entry.clone());                         changed_history.push(entry);+                        if was_merged {+                            let mut m = m;+                            m["file"] = json!(name);+                            m["legacyRoot"] = json!(lroot);+                            m["why"] = json!("changed since its copy");+                            m["atMs"] = json!(now_ms);+                            merged.push(m.clone());+                            merged_history.push(m);+                        }                         // 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");+                        r["sha256"] = incoming_sha.clone();+                        r["legacySha256"] = incoming_sha;+                        r["copiedAs"] = incoming_as;+                        r["comparedBy"] = json!(compared_by(bytes));+                        r["outcome"] = json!(if was_merged { "mergedAfterChange" } else { "copiedBesideAfterChange" });                     }                     Err(why) => failed.push(json!({                         "legacyRoot": lroot, "file": name, "why": why, "changedSinceCopy": true,@@ -549,21 +773,68 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {                 }             }             None => {-                let to = dest.join(&rel);-                let (target, outcome, why) = if !to.exists() {-                    (to.clone(), "copied", None)-                } else if same_bytes(&from, &to) {+                if to.exists() && same_bytes(&from, &to) {+                    let sha = crate::sha256::hex_of_file(&to);                     let entry = json!({                         "legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,-                        "sha256": crate::sha256::hex_of_file(&to), "copiedAs": name,+                        "sha256": sha, "legacySha256": sha, "copiedAs": name,+                        "comparedBy": compared_by(bytes),                         "outcome": "present",                         "why": "already present in the data root, byte for byte",                     });                     skipped.push(entry.clone());                     records.push(entry);                     continue;+                }+                // SAME NAME, DIFFERENT BYTES — two versions of one file, usually+                // from the two legacy roots. A ledger or a registry is MERGED: any+                // rule that PICKS one drops a history, and "the older root keeps+                // the name" picked the stale one on exactly Drew's timeline+                // (adversary pass 40). Anything else is copied beside, never over+                // it and never dropped.+                if let Some(kind) = merge_kind(&rel).filter(|_| to.is_file()) {+                    let live_sha_before = crate::sha256::hex_of_file(&to);+                    match merge_into(kind, &from, &to, &dest, &rel, &stamp) {+                        Ok(mut m) => {+                            // The copy that held the real name until now keeps a+                            // name of its own: if it is still exactly the copy a+                            // record describes, that record follows it beside.+                            if let Some(r) = records.iter_mut().find(|r| {+                                r["copiedAs"] == json!(name)+                                    && live_sha_before.is_some()+                                    && r["sha256"].as_str() == live_sha_before.as_deref()+                            }) {+                                r["copiedAs"] = m["liveBefore"]["copiedAs"].clone();+                            }+                            let entry = json!({+                                "legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,+                                "sha256": m["incoming"]["sha256"], "legacySha256": m["incoming"]["sha256"],+                                "copiedAs": m["incoming"]["copiedAs"],+                                "comparedBy": compared_by(bytes),+                                "outcome": "merged",+                                "why": "a different version of this file was already in the data root; the two were merged, and both are kept beside the merged file",+                            });+                            copied.push(entry.clone());+                            records.push(entry);+                            m["file"] = json!(name);+                            m["legacyRoot"] = json!(lroot);+                            m["why"] = json!("two versions of one file");+                            m["atMs"] = json!(now_ms);+                            merged.push(m.clone());+                            merged_history.push(m);+                            continue;+                        }+                        // A version that cannot be merged (not the shape a merge+                        // reads) is still never dropped: it goes beside, below.+                        Err(why) => merge_failed.push(json!({+                            "legacyRoot": lroot, "file": name,+                            "why": format!("could not be merged, so it was copied beside instead: {why}"),+                        })),+                    }+                }+                let (target, outcome, why) = if !to.exists() {+                    (to.clone(), "copied", None)                 } else {-                    // SAME NAME, DIFFERENT BYTES: never over it, never dropped.                     (                         beside_name(&dest, &rel, &stamp),                         "copiedBeside",@@ -574,7 +845,8 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {                     Ok(sha) => {                         let mut entry = json!({                             "legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,-                            "sha256": sha, "copiedAs": rel_of(&target), "outcome": outcome,+                            "sha256": sha, "legacySha256": sha, "copiedAs": rel_of(&target),+                            "comparedBy": compared_by(bytes), "outcome": outcome,                         });                         if let Some(why) = why {                             entry["why"] = json!(why);@@ -587,7 +859,9 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {             }         }     }-    if copied.is_empty() && skipped.is_empty() && failed.is_empty() && changed.is_empty() {+    if copied.is_empty() && skipped.is_empty() && failed.is_empty() && changed.is_empty()+        && merged.is_empty()+    {         return None;     }     let why = "older bridges kept their data inside ab's directories: 0.8.x in the bridge cache, \@@ -603,6 +877,8 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {         "failed": failed,         "skipped": skipped,         "changedSinceCopy": changed,+        "merged": merged,+        "mergeFailed": merge_failed,         "originalsLeftInPlace": true,         "why": why,     });@@ -615,8 +891,11 @@ pub fn migrate_legacy() -> Option<serde_json::Value> {         "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,+        "merged": merged_history,+        "mergeFailed": merge_failed,         "failed": failed,         "originalsLeftInPlace": true,+        "hashCapBytes": MIGRATION_HASH_CAP,         "lastRunAtMs": now_ms,         "why": why,     });@@ -926,6 +1205,40 @@ mod tests {         let _ = std::fs::remove_dir_all(&root);     } +    #[test]+    fn a_merged_ledger_holds_every_line_of_both_once_in_at_order() {+        let live = "{\"at\":30,\"action\":\"c\"}\n{\"at\":10,\"action\":\"a\"}\n";+        let incoming = "{\"at\":20,\"action\":\"b\"}\r\nnot json, after b\n{\"at\":10,\"action\":\"a\"}\n\n";+        let (merged, n) = merge_ledger_lines(live, incoming);+        assert_eq!(n, 4, "the shared line once, the blank line dropped: {merged:?}");+        let lines: Vec<&str> = merged.lines().collect();+        assert_eq!(+            lines,+            vec![+                "{\"at\":10,\"action\":\"a\"}",+                "{\"at\":20,\"action\":\"b\"}",+                // No `at` of its own: it stays beside the line written before it.+                "not json, after b",+                "{\"at\":30,\"action\":\"c\"}",+            ]+        );+        assert!(merged.ends_with('\n'));+        assert_eq!(merge_ledger_lines("", ""), (String::new(), 0));+    }++    #[test]+    fn a_merged_registry_holds_every_claim_of_both_once() {+        let a = r#"{"documents":[{"title":"Part1","session":7},{"title":"Part2","session":7}]}"#;+        let b = r#"{"documents":[{"title":"Part2","session":7},{"title":"Part9","session":9}]}"#;+        let (merged, n) = merge_registry(a, b).expect("both are registries");+        assert_eq!(n, 3);+        let v: serde_json::Value = serde_json::from_str(&merged).unwrap();+        let titles: Vec<&str> = v["documents"].as_array().unwrap().iter().map(|d| d["title"].as_str().unwrap()).collect();+        assert_eq!(titles, vec!["Part1", "Part2", "Part9"]);+        assert!(merge_registry(a, "not json").is_none(), "an unreadable registry is not merged");+        assert!(merge_registry(a, r#"{"rows":[]}"#).is_none(), "nor one of another shape");+    }+     #[test]     fn is_within_matches_by_components_not_by_prefix() {         let t = std::env::temp_dir().join(format!("swx-within-{}", std::process::id()));
src/server.rs+18−2
@@ -76,13 +76,29 @@ pub fn run(port: Option<u16>) {         let count = |k: &str| report[k].as_array().map(|a| a.len()).unwrap_or(0);         println!(             "[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)",+             an earlier copy; {} merged into the live file with both versions kept beside it; {} \+             failed verification and were NOT copied; originals left in place)",             count("copied"),             crate::scratch::root().display(),             count("changedSinceCopy"),+            count("merged"),             count("failed"),         );+        // A MERGE IS ITS OWN LINE IN THE LEDGER too: the live ledger or registry+        // was rewritten as the union of two versions, and both versions are kept+        // beside it under the names this records.+        if count("merged") > 0 {+            crate::ledger::record(+                "legacy_files_merged",+                serde_json::json!({+                    "merges": report["merged"].clone(),+                    "why": "two versions of the ledger or the ownership registry — from the two \+                            older locations, or one that changed after its copy — were MERGED: \+                            ledger lines united and ordered by `at`, registry claims united. A \+                            rule that keeps one version drops the other's history.",+                }),+            );+        }         // 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.
src/verbs3.rs+41−13
@@ -626,7 +626,7 @@ fn write_manifest(     } } -fn recovery_dir() -> std::path::PathBuf {+fn recovery_dir() -> Result<std::path::PathBuf, String> {     let now = std::time::SystemTime::now()         .duration_since(std::time::UNIX_EPOCH)         .map(|d| d.as_secs())@@ -677,24 +677,30 @@ fn recovery_dir() -> std::path::PathBuf {     // uniqueness was added to fix, reintroduced by the way it was implemented.     // `create_dir` FAILS if the directory is already there, so the creation IS     // the claim, and AlreadyExists simply means take the next name.+    // A FOLDER THAT CANNOT BE MADE IS AN ERROR, NOT A PATH (adversary pass 40).+    // This used to drop `create_dir_all`'s error and hand back the path anyway, so+    // a sweep on a blocked recovery location — no permission, a quota, a FILE in+    // the way — made one doomed SaveAs3 per document, on a SOLIDWORKS that may+    // already be struggling, and reported N save failures instead of one cause.     let base = crate::scratch::root().join("recovery");-    let _ = std::fs::create_dir_all(&base);+    std::fs::create_dir_all(&base).map_err(|e| {+        format!("the recovery folder {} cannot be created: {e}", base.display())+    })?;     for n in 0..1000 {         let d = if n == 0 { base.join(&stamp) } else { base.join(format!("{stamp}-{}", n + 1)) };         match std::fs::create_dir(&d) {-            Ok(()) => return d,+            Ok(()) => return Ok(d),             Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,-            // Anything else — no permission, no disk — is not a naming problem,-            // and the sweep's own headroom and write checks report it properly.-            Err(_) => {-                let _ = std::fs::create_dir_all(&d);-                return d;+            // Anything else — no permission, no disk — is not a naming problem.+            Err(e) => {+                return Err(format!("this sweep's folder {} cannot be created: {e}", d.display()))             }         }     }     let d = base.join(format!("{stamp}-{}", crate::launchos::epoch_ms()));-    let _ = std::fs::create_dir_all(&d);-    d+    std::fs::create_dir(&d)+        .map_err(|e| format!("this sweep's folder {} cannot be created: {e}", d.display()))?;+    Ok(d) }  /// IS A DIALOG ON SCREEN? The sweep must never reach a save prompt — every@@ -806,8 +812,23 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value {              — and run this again.",         );     }+    // A RECOVERY FOLDER THAT CANNOT BE MADE IS REFUSED ONCE, HERE — before the+    // first SaveAs, with the one cause, instead of being discovered one doomed+    // save at a time.+    let dir = match recovery_dir() {+        Ok(d) => d,+        Err(why) => {+            return err_code(+                "recovery_dir_unwritable",+                why,+                "Nothing was preserved and nothing was closed. The bridge could not create its \+                 recovery folder; the reason is above (permissions, a quota, a file where the \+                 folder must go). Fix that — or point SWX_SCRATCH_DIR somewhere writable — and run \+                 this again.",+            )+        }+    };     let job = ctx.jobs.start("close_all_safely", "Saving every document, then closing it", 120);-    let dir = recovery_dir();     let dir_s = dir.to_string_lossy().to_string();     let started = std::time::Instant::now();     let budget = std::time::Duration::from_secs(@@ -1921,12 +1942,19 @@ pub fn recover_list(ctx: &Ctx, _args: &Value) -> Value {     } else {         json!({             "changedSinceCopy": marker.get("changedSinceCopy").cloned().unwrap_or(json!([])),+            "merged": marker.get("merged").cloned().unwrap_or(json!([])),+            "mergeFailed": marker.get("mergeFailed").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.",+                      was copied again, beside the first copy (`copiedAs`), never over it. merged: \+                      the ledger or the ownership registry existed in two versions (two older \+                      locations, or a change after the copy), and the live file is now their \+                      UNION — ledger lines once each in `at` order, registry claims once each — \+                      with both versions kept beside it. mergeFailed: a version that could not \+                      be merged, kept beside instead. failed: files still only in the older \+                      location; the bridge retries them on start.",         })     };     ok(json!({
tests/adversary38.rsadded+387
@@ -0,0 +1,387 @@+//! ADVERSARIAL REVIEW OF 0.9.1 — pass forty.+//!+//! 0.9.1 is PUBLISHED. Every pass-39 finding landed, and the two that mattered+//! landed well: the data root left ab's directory for+//! `%LOCALAPPDATA%\adom-solidworks-bridge\`, and a legacy file is no longer+//! settled by name. Nothing below makes 0.9.1 worse than 0.9.0.+//!+//! WHAT CHECKS OUT+//!+//!  (1) `sha256.rs` is correct at every padding boundary — 0, 1, 55, 56, 57, 63,+//!      64, 65, 119, 120, 121, 127, 128 bytes, page sizes, 1 MiB and 1 MiB + 1 —+//!      against digests computed by Python's `hashlib` for this pass, both in+//!      one call and streamed in awkward chunk sizes (pinned below). It is used+//!      in exactly two places, both in `scratch.rs` and both for the MIGRATION+//!      RECORD: the digest of a copy after it has been byte-verified with+//!      `same_bytes` and renamed. Verification still never depends on it, and it+//!      is nowhere near the recovery marker in `host.rs`.+//!  (4) The never-ask pin now walks the whole repository and strips quotes only+//!      in a NAMED allowlist of five describing files; `references/*.md` is not+//!      on it. Measured: a blockquoted ask and a backticked ask planted in+//!      `references/closing-and-recovery.md` are each caught.+//!  (5) `data_root_is_temp` and `recovery_inside_install_dir` both refuse before+//!      `recovery_dir()` is even called, so before the first SaveAs. A temp root+//!      cannot strand a half-done sweep.+//!  (6) The rewritten no-profile test blanks `SWX_SCRATCH_DIR`, `LOCALAPPDATA`+//!      and `USERPROFILE`; `env_path` treats empty as unset, so the refusal is+//!      reached through the production `root_and_source`. `TMP`/`TEMP`/`TMPDIR`+//!      only choose WHERE temp is. Still pinned on the real path.+//!  (2, partly) `migrated-from-legacy.json` is NOT in `MIGRATED_ITEMS`, so 0.9.1+//!      never inherits 0.9.0's mtime-less records; every record it compares+//!      against was written by itself, with an mtime.++mod common;+use common::*;+use serde_json::{json, Value};+use std::path::{Path, PathBuf};+use std::time::{Duration, SystemTime};++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();+}++fn set_mtime(path: &Path, t: SystemTime) {+    std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();+}++/// `%LOCALAPPDATA%` with ab's layout and both legacy roots under it.+struct Layout {+    local: TempDir,+}+impl Layout {+    fn new(tag: &str) -> Layout {+        Layout { local: TempDir::new(tag) }+    }+    fn data(&self) -> PathBuf {+        self.local.0.join("adom-solidworks-bridge")+    }+    fn cache(&self) -> PathBuf {+        self.local.0.join("Adom Bridge").join("bridges-cache").join("solidworks")+    }+    fn v090(&self) -> PathBuf {+        self.local.0.join("Adom Bridge").join("solidworks-data")+    }+}++// ── (1) SHA-256 — PINNED AGAINST AN INDEPENDENT IMPLEMENTATION ──────────────++/// Input of length `n`: deterministic and non-repeating within a block, so a+/// mis-ordered word or a wrong schedule index cannot hide behind uniform bytes.+fn data(n: usize) -> Vec<u8> {+    (0..n).map(|i| ((i * 31 + 7) % 251) as u8).collect()+}++/// Digests computed by Python's `hashlib.sha256` over `data(n)` for this pass.+/// Every length on either side of a padding boundary: 55/56/57 (the length field+/// fits / does not fit in the last block), 63/64/65 and 119/120/121 (one block,+/// two blocks, the second boundary), 127/128, page sizes, and 1 MiB on both+/// sides — the streaming buffer in `hex_of_file` is exactly 1 MiB.+const HASHLIB: &[(usize, &str)] = &[+    (0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),+    (1, "ca358758f6d27e6cf45272937977a748fd88391db679ceda7dc7bf1f005ee879"),+    (55, "e40272620990d5f772101e014a0fe168d9ab13215739a04bdcb4b9cadc303c75"),+    (56, "5b3154594d65c2a11e797576e2b4ece47f48373d8acfc0ae2cc3b87ca7adaedc"),+    (57, "bd73274967a968a1d4353b4096741c1c880d2356d44c63f0e7916c8efe59bccc"),+    (63, "2caeed71c024d190f2f9633bdac2b8f283b1cb86dfe7709023ab70f11f747a1b"),+    (64, "3483dc12a3fea6618642162f88124ea56e797597a97483d5ba597a42d98aaf6b"),+    (65, "3a513663aae909c328f43e2d74265dc5ec35495c99dfde7b8f9dd37f297dfa40"),+    (119, "c06b1fcfb8d206bb4fe8e8cde10de7af236f619ff29c720de0083f7377491fd9"),+    (120, "f425d2567f5c636c99335e5e8bb04669475b3b8b6f7523bbabbd11115cef8550"),+    (121, "a75b2c7abda3c75c6be3edc54578301604c241b5cc1b71f060e78d019624610f"),+    (127, "2388ace1b8d638348c6720ec543c3dc1a2db3d73bf5c5f029536827b27b9b45f"),+    (128, "43a42610c7b657d1ce21bf0255e86be7ea01aa9dddf0701f596e99a4c6f61001"),+    (1000, "008549d94fa71e7a0a483d84380d05a923a4b18e79ba1f8a8ddac923956d32ef"),+    (4095, "efd2adfc2f8f96eab7aaf2245689e402dbe886ec91065947cbc4fd79297cd337"),+    (4096, "5ab775379b00e0ca28b6ee8c0b71b2e54307875de77c156356740d946a181be6"),+    (65535, "7dad0dd6a5a9d94d5c0af761d89d4def9e9bb415be9838d72700e702eb03b8f9"),+    (65536, "c2a19b29e9a734066ffb748d00176ca95e52545a0b0afe9e73f085740aeb97f8"),+    (1048576, "1c59b8670027384143781a8a8bff2f3b44bd8818d0f53b13b064c2375a1afe38"),+    (1048577, "455ced47859eda55f31afb1ed6e145648da8b8a50cc2fa1059abc4e356838615"),+];++/// Passes today. It exists so the hand-written SHA-256 stays correct at every+/// boundary its own tests do not name, in one call, streamed in awkward chunk+/// sizes, and through the file path the migration actually uses.+#[test]+fn sha256_matches_hashlib_at_every_padding_boundary() {+    use swx_bridge::sha256::{hex_of, hex_of_file, Sha256};+    let dir = TempDir::new("adv38-sha");+    for &(n, want) in HASHLIB {+        let d = data(n);+        assert_eq!(hex_of(&d), want, "one-shot digest of {n} bytes");+        for chunk in [1usize, 3, 55, 63, 64, 65, 4097] {+            let mut h = Sha256::new();+            for c in d.chunks(chunk) {+                h.update(c);+            }+            assert_eq!(h.hex(), want, "{n} bytes streamed in {chunk}-byte pieces");+        }+        let f = dir.join(&format!("v{n}.bin"));+        std::fs::write(&f, &d).unwrap();+        assert_eq!(hex_of_file(&f).as_deref(), Some(want), "file digest of {n} bytes");+    }+}++// ── (3) THE TWO-ROOT POLICY ─────────────────────────────────────────────────++/// SEVERITY 2 — "THE OLDER ROOT KEEPS THE REAL NAME" GIVES IT TO THE STALE COPY.+///+/// Legacy roots are processed oldest first — `bridges-cache\solidworks` (0.8.x),+/// then `Adom Bridge\solidworks-data` (0.9.0) — and a same-named file with+/// different bytes in the second is copied BESIDE the first as+/// `<name>.legacy-<stamp>`. "Older ROOT" is a proxy for "older FILE", and on+/// Drew's machine the proxy is backwards. The 2026-09-18 update wiped+/// `bridges-cache\solidworks`; 0.8.9 then RE-CREATED its ledger and registry+/// there; 0.9.0 copied those into `solidworks-data` and kept APPENDING. So+/// `solidworks-data\ledger.jsonl` is a strict superset of the cache's, and its+/// `owned-docs.json` is the newer registry. 0.9.1 gives the real names to the+/// cache's copies, and the 0.9.0 history goes into a side file nothing reads.+///+/// Nothing is DROPPED — the policy is honest about that, and recovery folders+/// are unaffected (a sweep folder present in both roots is byte-identical, so+/// the second is recorded `present`). What regresses is the LIVE state:+/// `swx_action_ledger` reads `ledger.jsonl`, so the 0.9.0 era vanishes from the+/// audit log; `owned::restore` reads `owned-docs.json`, so documents 0.9.0+/// created are no longer recognised as the bridge's own (fail-safe: left open).+///+/// AND "NEWEST MTIME WINS" IS NOT THE FIX. That is right for the history above+/// (0.9.0's copy is a superset and newer) and wrong for the one described for+/// Drew's machine: a cache RE-CREATED by 0.8.9 after 0.9.0 wrote its own data,+/// newer by mtime and stale in content. There the two ledgers are DIVERGENT —+/// each holds entries the other lacks — and any rule that picks ONE file for the+/// real name drops one history from the live ledger. An append-only ledger needs+/// a MERGE (dedupe by line, order by `at`); a registry needs a UNION (every+/// claim is re-validated against the live session anyway). The next test pins+/// the divergent case so a mtime rule cannot pass for the wrong reason.+#[test]+fn the_live_ledger_after_migration_is_the_newest_one_not_the_oldest_roots() {+    let lay = Layout::new("adv38-tworoots");+    let old = SystemTime::now() - Duration::from_secs(3 * 86_400);+    let new = SystemTime::now() - Duration::from_secs(3_600);+    let base = "{\"at\":1789000000,\"action\":\"document_created\",\"detail\":{\"title\":\"CACHE-ERA\"}}\n";+    let superset = format!(+        "{base}{{\"at\":1790800000,\"action\":\"close_all_safely\",\"detail\":{{\"title\":\"V090-ERA\"}}}}\n"+    );+    write(&lay.cache().join("ledger.jsonl"), base.as_bytes());+    set_mtime(&lay.cache().join("ledger.jsonl"), old);+    write(&lay.v090().join("ledger.jsonl"), superset.as_bytes());+    set_mtime(&lay.v090().join("ledger.jsonl"), new);++    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let _ = b.call("swx_recover_list", json!({}));+    drop(b);++    let live = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();+    assert!(live.contains("CACHE-ERA"), "precondition: the migration ran: {live:?}");+    let side: Vec<String> = std::fs::read_dir(lay.data())+        .map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect())+        .unwrap_or_default();++    assert!(+        live.contains("V090-ERA"),+        "the live ledger.jsonl after migration holds the 0.8.x cache's history and NOT the newer \+         0.9.0 entries, although 0.9.0's ledger is a strict superset with the later mtime. \"The \+         older ROOT keeps the real name\" gave the name to the STALE file: on Drew's machine the \+         cache was wiped on 09-18 and re-created by 0.8.9, then copied and appended to by 0.9.0. \+         The 0.9.0 era is in a side file swx_action_ledger never reads, and the same rule hands \+         owned-docs.json to the older registry. Merge the ledgers; union the registries. \+         \n  live ledger: {live:?}\n  data root: {side:?}"+    );+}++/// THE DIVERGENT CASE — both roots hold entries the other lacks, and the OLDER+/// root's file is the NEWER one by mtime (a cache re-created by 0.8.9 after 0.9.0+/// had written its own data). Today the cache keeps the real name; a "newest+/// mtime wins" fix would keep it too. Either way the 0.9.0 entry leaves the live+/// ledger. Only a merge passes this.+#[test]+fn divergent_ledgers_in_the_two_roots_are_merged_not_picked() {+    let lay = Layout::new("adv38-divergent");+    let older = SystemTime::now() - Duration::from_secs(2 * 86_400);+    let newer = SystemTime::now() - Duration::from_secs(600);+    write(+        &lay.cache().join("ledger.jsonl"),+        b"{\"at\":1790900000,\"action\":\"document_closed\",\"detail\":{\"title\":\"ONLY-IN-CACHE\"}}\n",+    );+    set_mtime(&lay.cache().join("ledger.jsonl"), newer);+    write(+        &lay.v090().join("ledger.jsonl"),+        b"{\"at\":1790800000,\"action\":\"close_all_safely\",\"detail\":{\"title\":\"ONLY-IN-V090\"}}\n",+    );+    set_mtime(&lay.v090().join("ledger.jsonl"), older);++    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let _ = b.call("swx_recover_list", json!({}));+    drop(b);++    let live = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();+    assert!(+        live.contains("ONLY-IN-CACHE") || live.contains("ONLY-IN-V090"),+        "precondition: the migration ran: {live:?}"+    );+    assert!(+        live.contains("ONLY-IN-CACHE") && live.contains("ONLY-IN-V090"),+        "two DIVERGENT ledgers — each with an entry the other lacks — and the live ledger keeps \+         only one of them. Picking a file by root age drops one history; picking by mtime would \+         drop the other one in this case. An append-only ledger has to be merged. \+         live ledger: {live:?}"+    );+}++// ── (2) CHANGE DETECTION ────────────────────────────────────────────────────++/// SEVERITY 3 — THE LEGACY SIDE IS NEVER HASHED, SO A SAME-SIZE, SAME-MTIME+/// REWRITE IS SETTLED.+///+/// `differs` compares SIZE and MTIME only. The recorded sha256 is the COPY's; it+/// is never compared with the legacy file's current bytes. Two consequences:+///+///   * a rewrite that keeps the length and the mtime is settled silently. A+///     bridge writing `owned-docs.json` cannot cause that — `write_atomic`+///     makes a new file with a new mtime — so the ordinary rollback IS caught.+///     What causes it is anything that RESTORES mtimes: a File History or+///     backup restore, `robocopy /COPY:DAT`, a sync client re-materialising a+///     file. Equal length is the norm for this registry: scratch names are+///     `Part<N>-<13-digit epoch ms>.SLDPRT`, so swapping one entry for another+///     rarely changes the byte count.+///   * mtime granularity is a false-positive risk, not a false-negative one:+///     FAT/exFAT's 2 s and a copy tool rounding to it change the mtime without+///     changing the bytes, which copies the file again beside itself. Harmless,+///     noisy.+///+/// The ledger is append-only, so its size always moves: safe. The registries are+/// a few KB: hash them. Re-hashing the legacy side of any file under a few MB on+/// a size+mtime match costs milliseconds and turns "probably the same" into+/// "the same".+#[test]+fn a_same_size_same_mtime_rewrite_of_a_legacy_registry_is_not_settled() {+    let lay = Layout::new("adv38-samesize");+    let reg = lay.cache().join("owned-docs.json");+    let a = r#"{"documents":[{"title":"Part1.SLDPRT","path":"C:\\x\\Part1-1790000000001.SLDPRT"}]}"#;+    let b = r#"{"documents":[{"title":"Part2.SLDPRT","path":"C:\\x\\Part2-1790000000002.SLDPRT"}]}"#;+    assert_eq!(a.len(), b.len(), "precondition: an equal-length rewrite");+    write(&reg, a.as_bytes());+    let t = SystemTime::now() - Duration::from_secs(7_200);+    set_mtime(&reg, t);++    let local = lay.local.path();+    let env = [("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())];+    {+        let br = Bridge::start_with(&env);+        let _ = br.call("swx_recover_list", json!({}));+    }+    let first = std::fs::read_to_string(lay.data().join("owned-docs.json")).unwrap_or_default();+    assert!(first.contains("Part1-1790000000001"), "precondition: the first copy landed: {first:?}");++    // A restore puts different bytes back with the ORIGINAL mtime.+    write(&reg, b.as_bytes());+    set_mtime(&reg, t);+    {+        let br = Bridge::start_with(&env);+        let _ = br.call("swx_recover_list", json!({}));+    }++    let mut seen = String::new();+    if let Ok(rd) = std::fs::read_dir(lay.data()) {+        for e in rd.flatten() {+            if e.file_name().to_string_lossy().starts_with("owned-docs.json") {+                seen.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default());+            }+        }+    }+    let marker = std::fs::read_to_string(lay.data().join("migrated-from-legacy.json")).unwrap_or_default();+    assert!(+        seen.contains("Part2-1790000000002") || marker.contains("Part2-1790000000002"),+        "the legacy owned-docs.json was rewritten with DIFFERENT bytes of the SAME length and \+         its mtime put back — what a backup restore or robocopy /COPY:DAT does — and 0.9.1 \+         settled it: `differs` compares size and mtime, and the sha256 it records is the COPY's, \+         never compared with the legacy file. Equal length is the norm for this registry \+         (Part<N>-<13-digit epoch>). Hash the legacy side of small files on a size+mtime match.\+         \n  marker: {marker}"+    );+}++// ── (5) AN UNWRITABLE RECOVERY FOLDER ───────────────────────────────────────++fn a_session_with_unsaved_work(root: &Path) {+    std::fs::create_dir_all(root).unwrap();+    std::fs::write(+        root.join("mock-solidworks-open-docs.json"),+        json!({+            "running": true, "untitled": 1, "active": "untitled:a",+            "docs": [+                {"docType": "part", "path": "untitled:a", "title": "Imported.step.SLDPRT",+                 "saved": false, "dirty": true},+                {"docType": "part", "path": "C:\\Users\\drew\\work\\bracket.SLDPRT",+                 "title": "bracket.SLDPRT", "saved": true, "dirty": true}+            ],+            "files": {}+        })+        .to_string(),+    )+    .unwrap();+}++/// SEVERITY 3 — A RECOVERY FOLDER THAT CANNOT BE CREATED IS DISCOVERED ONE SAVE+/// AT A TIME, NOT REFUSED BEFORE THE FIRST.+///+/// The temp-root and inside-install refusals fire before anything is written.+/// Neither covers the case the coordinator named: a root whose `recovery` folder+/// cannot be created — `USERPROFILE` set but unwritable, a permissions or quota+/// problem, a file in the way. `recovery_dir()` calls+/// `let _ = std::fs::create_dir_all(&first); return first;` — the error is+/// DROPPED, the path returned as if it existed, and the sweep proceeds to issue+/// one `SaveAs3` per document into a folder that is not there.+///+/// It is SAFE — each save fails, each document is `preserved:false` and stays+/// open, nothing is closed. The cost is everything else: N doomed COM calls on a+/// SOLIDWORKS that may already be struggling (on a session of leaked import+/// temporaries N is in the hundreds), N identical failure records instead of one+/// cause, a manifest that cannot be written, and a reply that reads like N+/// separate save problems. One `create_dir_all(...)?` and a `recovery_dir_unwritable`+/// refusal, before the first SaveAs, says it once.+#[test]+fn a_recovery_folder_that_cannot_be_created_is_refused_before_the_first_save() {+    let scratch = TempDir::new("adv38-unwritable");+    a_session_with_unsaved_work(&scratch.0);+    // A FILE where the recovery folder must go: create_dir_all cannot succeed,+    // for any user, root included.+    std::fs::write(scratch.join("recovery"), b"not a directory").unwrap();+    let machine = TempDir::new("adv38-unwritable-machine");+    std::fs::write(machine.join("windows.json"), "[]").unwrap();+    let b = Bridge::start_in(+        TempDir::borrowed(&scratch.0),+        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],+    );+    b.call("swx_start", json!({}));+    let before = b.call("swx_docs", json!({}));+    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));+    let after = b.call("swx_docs", json!({}));+    assert!(+        before["count"].as_u64().unwrap_or(0) >= 2 && before["count"] == after["count"],+        "precondition (the safe half): nothing was closed. before {} after {}",+        s(&before),+        s(&after)+    );++    let attempts = r["recovered"].as_array().map(|a| a.len()).unwrap_or(0);+    assert!(+        attempts == 0 && r["success"] == json!(false),+        "the recovery folder could not be created — a FILE sits where it must go — and the sweep \+         did not refuse: it made {attempts} save attempt(s) into a folder that does not exist. \+         recovery_dir() drops create_dir_all's error and returns the path anyway. Safe (every \+         document stays open) but it costs one doomed SaveAs3 per document on a SOLIDWORKS that \+         may already be struggling, and reports N save failures instead of one cause. Refuse with \+         a single code before the first SaveAs. Reply: {}",+        s(&r)+    );+}
tests/data_root.rs+242−36
@@ -932,11 +932,14 @@ fn the_data_root_is_a_directory_only_this_bridge_writes() {  /// 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.+/// names simply move. A LEDGER in both is MERGED (0.9.2): 0.9.1 gave the real+/// name to the first root's copy and put the second BESIDE it, and adversary pass+/// 40 showed that picks the stale one on exactly Drew's timeline — the cache+/// re-created by 0.8.9 after 0.9.0 had copied it and kept appending, so the 0.9.0+/// era sat in a side file `swx_action_ledger` never reads. This test pinned that+/// naming rule; it is changed ON PURPOSE: the live ledger is now the union of+/// both, ordered by `at`, and both originals are kept beside it. 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");@@ -981,14 +984,31 @@ fn the_0_9_0_data_root_is_migrated_after_the_install_cache_and_nothing_is_lost()                 .collect()         })         .unwrap_or_default();+    // THE LIVE LEDGER IS THE MERGE: every line of both, each once, in `at` order+    // — here that is exactly 0.9.0's (a superset of the cache's), followed by+    // whatever this start itself recorded.+    let live = read("ledger.jsonl");+    let mut beside_contents: Vec<Vec<u8>> = ledger_besides.iter().map(|n| read(n)).collect();+    beside_contents.sort();+    let mut want = vec![cache_ledger.clone(), data_ledger.clone()];+    want.sort();     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,+        "migration: two ledgers are MERGED into the live one, and BOTH originals are kept beside it",+        live.starts_with(&data_ledger) && beside_contents == want,         format!("{ledger_besides:?}\n{}", s(&marker))     );+    let merge = marker["merged"].as_array().and_then(|a| a.iter().find(|m| m["file"] == "ledger.jsonl").cloned()).unwrap_or(json!({}));+    check!(+        b,+        "migration: ...and the merge is on the record, with where each original went and what the result holds",+        merge["kind"] == "ledger"+            && merge["result"]["entries"] == 2+            && merge["liveBefore"]["copiedAs"].as_str().map(|n| n.starts_with("ledger.jsonl.legacy-")).unwrap_or(false)+            && merge["incoming"]["copiedAs"].as_str().map(|n| n.starts_with("ledger.jsonl.legacy-")).unwrap_or(false)+            && ledger_count(&data, "legacy_files_merged") == 1,+        s(&marker)+    );     check!(         b,         "migration: the order is recorded, oldest first, and 0.9.0's own record is not copied as data",@@ -1145,19 +1165,29 @@ fn a_legacy_file_that_changes_after_its_copy_is_copied_again_beside_it() {     let b = Bridge::start_with(&env);     let ledger_besides = besides("ledger.jsonl.legacy-");     let owned_besides = besides("owned-docs.json.legacy-");+    // 0.9.2: A CHANGED LEDGER IS MERGED INTO THE LIVE ONE (adversary pass 40).+    // 0.9.1 kept the rollback's entries only in a side file, which+    // `swx_action_ledger` never reads — the same defect as picking one of two+    // roots' ledgers. Now the live ledger holds both histories, and BOTH originals+    // are kept beside it: the live file as it was before the merge, and the+    // changed legacy version.+    let live_text = String::from_utf8_lossy(&std::fs::read(data.join("ledger.jsonl")).unwrap_or_default()).to_string();     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:?}")+        "migration: the CHANGED legacy ledger is MERGED into the live one — both histories, each line once",+        live_text.contains("BEFORE")+            && live_text.contains("DURING-ROLLBACK")+            && live_text.matches("\"title\":\"BEFORE\"").count() == 1,+        live_text.clone()     );+    let beside_ledgers: Vec<Vec<u8>> = ledger_besides.iter().map(|n| std::fs::read(data.join(n)).unwrap_or_default()).collect();     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()+        "migration: ...and both versions are kept beside it: the live file as it was, and the changed one",+        ledger_besides.len() == 2+            && beside_ledgers.iter().any(|v| v == &rolled)+            && beside_ledgers.iter().any(|v| v.starts_with(&before) && !String::from_utf8_lossy(v).contains("DURING-ROLLBACK")),+        format!("{ledger_besides:?}")     );     let timings_besides = besides("timings.json.legacy-");     check!(@@ -1175,13 +1205,20 @@ fn a_legacy_file_that_changes_after_its_copy_is_copied_again_beside_it() {             && std::fs::read(data.join(&settings_besides[0])).ok() == Some(br#"{"badge":"no!"}"#.to_vec()),         format!("{settings_besides:?}")     );+    // THE REGISTRY IS UNITED, not picked: the live registry now proposes the+    // rollback's claim too (restore re-validates every claim against the live+    // session), and both versions are kept beside it.+    let live_owned: Value = serde_json::from_str(&std::fs::read_to_string(data.join("owned-docs.json")).unwrap_or_default()).unwrap_or(json!({}));     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:?}")+        "migration: the rewritten ownership registry is UNITED with the live one, both versions kept beside",+        live_owned["documents"].as_array().map(|a| a.iter().any(|d| d["title"] == "Part9" && d["session"] == 4242)).unwrap_or(false)+            && owned_besides.len() == 2+            && owned_besides.iter().any(|n| {+                std::fs::read(data.join(n)).ok()+                    == Some(br#"{"documents":[{"title":"Part9","session":4242}]}"#.to_vec())+            }),+        format!("{owned_besides:?} {}", s(&live_owned))     );     let m = marker();     let changed = m["changedSinceCopy"].as_array().cloned().unwrap_or_default();@@ -1192,14 +1229,19 @@ fn a_legacy_file_that_changes_after_its_copy_is_copied_again_beside_it() {         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())+            && ledger_change["copiedAs"]+                .as_str()+                .map(|c| ledger_besides.iter().any(|n| n == c) && std::fs::read(data.join(c)).ok() == Some(rolled.clone()))+                .unwrap_or(false)+            && ledger_change["mergedInto"] == "ledger.jsonl"             && 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,+        "migration: ...and the ledger says so in lines of its own — the change, and the merge",+        ledger_count(&data, "legacy_file_changed_since_copy") == 1+            && ledger_count(&data, "legacy_files_merged") == 1,         std::fs::read_to_string(data.join("ledger.jsonl")).unwrap_or_default()     );     let l = b.call("swx_recover_list", json!({}));@@ -1216,28 +1258,33 @@ fn a_legacy_file_that_changes_after_its_copy_is_copied_again_beside_it() {     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),+        "migration: an unchanged legacy file is not copied or merged again",+        besides("ledger.jsonl.legacy-").len() == 2+            && marker()["changedSinceCopy"].as_array().map(|a| a.len()) == Some(4)+            && ledger_count(&data, "legacy_files_merged") == 1,         s(&marker())     );     drop(b); -    // IT CHANGES AGAIN: a second copy, under another name — the first beside copy-    // is never overwritten either.+    // IT CHANGES AGAIN: merged again, two more copies beside — and the earlier+    // copies beside are never overwritten.+    let earlier: Vec<(String, Vec<u8>)> =+        ledger_besides.iter().map(|n| (n.clone(), std::fs::read(data.join(n)).unwrap_or_default())).collect();     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-");+    let live_now = std::fs::read_to_string(data.join("ledger.jsonl")).unwrap_or_default();     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:?}")+        "migration: a second change is merged too, and every earlier copy beside stays as it was",+        ledger_besides_now.len() == 4+            && earlier.iter().all(|(n, v)| std::fs::read(data.join(n)).ok().as_ref() == Some(v))+            && ledger_besides_now.iter().any(|n| std::fs::read(data.join(n)).ok() == Some(again.clone()))+            && live_now.contains("AGAIN")+            && live_now.matches("DURING-ROLLBACK").count() == 1,+        format!("{ledger_besides_now:?}\n{live_now}")     ); } @@ -1318,3 +1365,162 @@ fn with_no_profile_location_every_sweep_refuses_rather_than_write_to_temp() {         s(&r)     ); }++/// TWO REGISTRIES ARE UNITED, NOT PICKED — and one that cannot be merged is+/// still never dropped (adversary pass 40, E1).+///+/// The ownership registry in each older location holds the claims of the bridge+/// that wrote it. Keeping one copy under the real name disowns the other's+/// documents (fail-safe, but wrong); the union proposes every claim, and+/// `owned::restore` re-validates each against the live session anyway. A+/// registry that is not one this bridge wrote cannot be merged: it is copied+/// beside the live one, reported `mergeFailed`, and the live one is untouched.+#[test]+fn two_registries_are_united_and_an_unmergeable_one_is_kept_beside() {+    let lay = Layout::new("dataroot-registries");+    write(&lay.legacy().join("owned-docs.json"), br#"{"documents":[{"title":"PartA","session":1}]}"#);+    write(&lay.legacy_data().join("owned-docs.json"), br#"{"documents":[{"title":"PartB","session":2}]}"#);+    write(&lay.legacy().join("com-host-created.json"), br#"{"a":1}"#);+    // Not a registry (no `documents` list): the merge must refuse and keep it.+    write(&lay.legacy().join("settings.json"), br#"{"badge":true}"#);+    write(&lay.legacy_data().join("settings.json"), br#"{"badge":false}"#);+    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let data = lay.data();+    let live: Value =+        serde_json::from_str(&std::fs::read_to_string(data.join("owned-docs.json")).unwrap_or_default())+            .unwrap_or(json!({}));+    let titles: Vec<String> = live["documents"]+        .as_array()+        .map(|a| a.iter().filter_map(|d| d["title"].as_str().map(|t| t.to_string())).collect())+        .unwrap_or_default();+    check!(+        b,+        "migration: the live registry is the UNION of both older registries",+        titles == vec!["PartA".to_string(), "PartB".to_string()],+        s(&live)+    );+    let marker: Value = serde_json::from_str(+        &std::fs::read_to_string(data.join("migrated-from-legacy.json")).unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    check!(+        b,+        "migration: ...recorded as a registry merge with both originals beside it",+        marker["merged"]+            .as_array()+            .map(|a| a.iter().any(|m| m["file"] == "owned-docs.json" && m["kind"] == "registry" && m["result"]["entries"] == 2))+            .unwrap_or(false),+        s(&marker)+    );+    // settings.json is not a mergeable file: the second version goes beside, the+    // first keeps the name — as before 0.9.2.+    let settings_beside: 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("settings.json.legacy-"))+                .collect()+        })+        .unwrap_or_default();+    check!(+        b,+        "migration: a file that is not a ledger or a registry is still kept beside, not merged",+        std::fs::read(data.join("settings.json")).ok() == Some(br#"{"badge":true}"#.to_vec())+            && settings_beside.len() == 1+            && std::fs::read(data.join(&settings_beside[0])).ok() == Some(br#"{"badge":false}"#.to_vec()),+        format!("{settings_beside:?}")+    );+    drop(b);++    // A REGISTRY THAT CANNOT BE MERGED. A third location is not needed: make the+    // older registry change into something unmergeable, and start again.+    write(&lay.legacy_data().join("owned-docs.json"), b"{not json");+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let after: Value =+        serde_json::from_str(&std::fs::read_to_string(data.join("owned-docs.json")).unwrap_or_default())+            .unwrap_or(json!({}));+    let kept_beside = std::fs::read_dir(&data)+        .map(|d| {+            d.flatten().any(|e| {+                e.file_name().to_string_lossy().starts_with("owned-docs.json.legacy-")+                    && std::fs::read(e.path()).ok() == Some(b"{not json".to_vec())+            })+        })+        .unwrap_or(false);+    let marker2: Value = serde_json::from_str(+        &std::fs::read_to_string(data.join("migrated-from-legacy.json")).unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    check!(+        b,+        "migration: an unmergeable registry leaves the live one untouched and is kept beside, never dropped",+        after == live+            && kept_beside+            && marker2["mergeFailed"].as_array().map(|a| a.iter().any(|e| e["file"] == "owned-docs.json")).unwrap_or(false),+        format!("{}\n{}", s(&after), s(&marker2))+    );+}++/// THE SAME SIZE AND MTIME: HASHED UNDER THE CAP, SAID OUT LOUD OVER IT+/// (adversary pass 40, E2). Every record says how its file is compared.+#[test]+fn a_record_says_whether_its_file_is_compared_by_hash_or_only_by_size_and_mtime() {+    let lay = Layout::new("dataroot-hashcap");+    write(&lay.legacy().join("owned-docs.json"), br#"{"documents":[]}"#);+    let big = vec![b'x'; 16 * 1024 * 1024 + 1];+    write(&lay.legacy().join("exports").join("big.step"), &big);+    let local = lay.local.path();+    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);+    let marker: Value = serde_json::from_str(+        &std::fs::read_to_string(lay.data().join("migrated-from-legacy.json")).unwrap_or_default(),+    )+    .unwrap_or(json!({}));+    let rec = |f: &str| {+        marker["copied"]+            .as_array()+            .and_then(|a| a.iter().find(|r| r["file"] == f).cloned())+            .unwrap_or(json!({}))+    };+    check!(+        b,+        "migration: a small file is compared by size, mtime AND sha256 of the legacy file",+        rec("owned-docs.json")["comparedBy"] == "size+mtime+sha256"+            && rec("owned-docs.json")["legacySha256"].as_str().map(|h| h.len() == 64).unwrap_or(false),+        s(&marker)+    );+    check!(+        b,+        "migration: a file over the 16 MiB cap is compared by size and mtime only — and its record SAYS so",+        rec("exports/big.step")["comparedBy"] == "size+mtime (over the 16 MiB hash cap)"+            && marker["hashCapBytes"] == 16 * 1024 * 1024,+        s(&rec("exports/big.step"))+    );+}++/// A RECOVERY FOLDER THAT CANNOT BE MADE IS REFUSED ONCE, BY NAME, BEFORE THE+/// FIRST SAVE (adversary pass 40, E3) — and nothing is written or closed.+#[test]+fn a_recovery_folder_that_cannot_be_made_is_refused_once_by_name() {+    let scratch = TempDir::new("dataroot-unwritable");+    a_session_with_unsaved_work(&scratch.0);+    std::fs::write(scratch.join("recovery"), b"a file where the folder must go").unwrap();+    let machine = a_visible_machine("dataroot-unwritable-machine");+    let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+    b.call("swx_start", json!({}));+    let r = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close SOLIDWORKS"}));+    check!(+        b,+        "swx_close_all_safely: refuses with recovery_dir_unwritable, naming the cause once",+        r["success"] == false+            && r["errorCode"] == "recovery_dir_unwritable"+            && r["recovered"].is_null(),+        s(&r)+    );+    check!(+        b,+        "swx_close_all_safely: ...before anything was written: the file in the way is untouched",+        std::fs::read(scratch.join("recovery")).ok() == Some(b"a file where the folder must go".to_vec()),+        String::new()+    );+}