main
Drew Owens 0.11.2 source 784acbf 5d ago

Launch resilience (v0.4.0)

Drew, 2026-09-04: "figure out how to recover from daemon kills in the future. This bridge must be extremely robust."

This is the record of what was measured on Drew's machine, what the bridge now does about it, and — kept separate and explicit — what is still unproven until tools/launch-gate.py runs against a live SOLIDWORKS.


1. What was measured

All of this is from Drew's desktop on 2026-09-03/04. Raw captures: healthy-launch-tree.txt, launch-logs.txt.

The sanctioned launch

SOLIDWORKS Design.lnk (Start-Menu group Dassault Systemes SOLIDWORKS 3DEXPERIENCE R2026x), windowStyle 1 (normal):

CATSTART.exe -run "SWXDesktopLauncher.exe"
             -object "--AppName=\"SWXCSWK_AP\" -tenant=OI000616940 -monoapp
                      -3DRegistryURL=https://eu1-makers-registry.3dexperience.3ds.com"
             -nowindow

A HEALTHY launch, from the launcher's own log (%APPDATA%\DassaultSystemes\SWXDesktopLauncher\<yyyyMMddHHmmss>_V6R2026x.HF4.13.log):

t thread what it logs
+0s first version banner, tenant=, AppName=, SecurityContext:
+2s second Run Begin, then Located …\sldworks.exe majorversion: 34
+2s %APPDATA%\DassaultSystemes\SWXDesktopHelper\<ts>.log appears (sign-in / licence)
+3s sldworks.exe appears; its parent is already gone
+1-3min COM attach succeeds

Two things follow, and both matter:

  • Run Begin is the signal. It is logged by a SECOND thread ~2 seconds after SecurityContext:, and everything downstream depends on it.
  • The 3DEXPERIENCE daemons are NOT required. The successful launch on 2026-09-04 had no Backbone running at all. 3DEXPERIENCELauncherSysTray.exe was up; 3DEXPERIENCELauncher.exe ran as a service. Nothing this bridge does needs to touch any of them.

FAILURE MODE A — the launcher stalls at SecurityContext:

5 of 5 bridge launches on 2026-09-04, and 1 of 2 on 2026-09-03: cmd.exe /c start "" "<lnk>" spawned with DETACHED_PROCESS | CREATE_NO_WINDOW produced a launcher log that stopped at SecurityContext:. No Run Begin, no helper log, no sldworks.exe. CATSTART.exe + SWXDesktopLauncher.exe sat idle at ~50 MB forever.

Every launch from an INTERACTIVE context — a Start-Menu click, the crash reporter's Restart SOLIDWORKS Design button — succeeded, with identical arguments.

Working hypothesis (NOT proven): the hidden/detached startup context — the inherited STARTUPINFO show state, CREATE_NO_WINDOW, no console — stops the launcher's Run thread from starting. The bridge is built so this hypothesis does not have to be right: it tries routes in order, detects the stall from the launcher's own log, cleans up, and moves on.

FAILURE MODE B — no in-flight tracking (design bug)

swx_start's hint said "call swx_start again, which ATTACHES rather than launching a second copy". There was no in-flight state anywhere in the code, so every such call fired the shortcut again. Four launcher chains accumulated on Drew's desktop. The return value was right and the effect was wrong.

FAILURE MODE C — crash on first spawn

2026-09-03: sldworks.exe crashed on first spawn and the SOLIDWORKS crash reporter appeared, with a "Restart SOLIDWORKS Design" button. Clicking it launched successfully.

FAILURE MODE D — hung, and exhausted

  • Hung: Windows Not Responding, CPU idle, COM status timing out at 20s. The bridge cannot end it (policy, and the auto-mode classifier blocks it). Only the user can.
  • Exhausted: 17.4 GB private, 21,770 handles, 52 documents open — and BOTH the part and the assembly stock templates returned NO document. The bridge reported it as a template problem, which sent the caller looking for a fault in a template that was fine.

FAILURE MODE E — ownership lost (bug #5)

The ownership registry was memory-only, so a bridge restart disowned 23 open crate documents, and a com-host respawn mid-build disowned an assembly created ninety seconds earlier. See §5.


2. The state machine

src/launch.rs. One supervisor thread per attempt, started by swx_start and outliving the HTTP request that started it — so a launch keeps making progress whether or not anybody is polling, and swx_start is a pure OBSERVER of it.

                 ┌──────────────────────────────── next route ◀─┐
                 ▼                                              │
  invoke route ──▶ invoked ──▶ launcherStarted ──▶ runBegin ──▶ helperSignIn
                                     │                │              │
                     no Run Begin in 20s              │              ▼
                                     │                │         sldworksUp
                                     ▼                │              │
                                  STALLED ────────────┴──▶ no sldworks in 120s
                                (kill OUR chain)                     │
                                                                     ▼
                                                                 attaching
                                                                     │
                                                    COM attach within 240s
                                                                     ▼
                                                                 ATTACHED
Stage Evidence, measured
invoked the com host ran the route
launcherStarted a launcher log created at/after our invoke exists
runBegin that log contains Run Begin
helperSignIn a SWXDesktopHelper log created at/after our invoke
sldworksUp / attaching sldworks.exe in the process census
attached start {launch:false} succeeded — the only success
stalled a deadline passed; OUR chain was ended; next route
exhausted every route tried; ask the user

Routes, in order (launch::ROUTES):

# Route Mechanism Why
1 explorer explorer.exe "<lnk>" the shell resolves and runs the link in its own normal show state — closest to a double-click
2 shellExecute ShellExecuteExW, SW_SHOWNORMAL, on the com host's STA thread an explicit show state, no shell-out at all
3 powershell Start-Process -FilePath '<lnk>' Start-Process does not pass on the caller's show state; a third independent mechanism
4 cmdStart cmd /c start "" "<lnk>", DETACHED_PROCESS|CREATE_NO_WINDOW the route that stalled 5/5. Kept LAST, not deleted: it is the one route proven to at least start the chain here

The route that reaches attached is written to launch-state.json as preferredRoute and tried FIRST next time, so a machine pays the fallback cost once. A remembered route that is not a known route is ignored, never run.

Budgets, per stage (env overrides exist only so the suite can compress them):

Stage What counts as a stall Budget
invoked / launcherStarted no Run Begin SWX_LAUNCH_RUN_BEGIN_SECS 20
runBegin (no helper log yet) no sldworks.exe SWX_LAUNCH_SLDWORKS_SECS 120
helperSignIn (0.11.2) the clocks are PAUSED. Only a DEAD chain (every process of it gone, no sldworks.exe) is a stall. Past SWX_LAUNCH_HELPER_SECS (600) the launch STOPS: helperSignInTooLong, nothing killed, nothing re-invoked 600, then stop
3DEXPERIENCE ID sign-in window up paused (0.6.x) none
sldworksUp / attaching COM does not attach → stop, never a stall SWX_LAUNCH_ATTACH_SECS 240

SWX_LAUNCH_INFLIGHT_SECS 240 · SWX_LAUNCH_RETRY_SETTLE_MS 15000 · SWX_LAUNCH_DIALOG_GRACE_MS 90000.

A retry never re-invokes over a running SOLIDWORKS (0.11.2)

THE INCIDENT, from the laptop's ledger (2026-10-15, attempt 24, route platformShortcut):

Time (Z) Event
14:48:58 solidworks_launch_started
14:49:02 launcherStarted
14:49:04 runBegin, helperSignIn
14:51:11 stage stalled + solidworks_launch_stalled {cleanup:"cleaned", killed:[CATSTART.exe 39612, …]}
14:51:11 stage invoked: the shortcut invoked AGAIN
14:51:14 / :16 launcherStarted, runBegin
14:51:17 sldworksUp, 6 s after the re-invoke: the FIRST invoke's SOLIDWORKS, already coming up
14:51:53 attached, pid 82476, attributed by ancestry
15:00:15 first sighting of the dialog: stop_blocked_by_dialog "Open "

The "stall" was false: the 3DEXPERIENCE helper sign-in simply took more than two minutes. The second invoke reached the already-starting Connected session as an "open" request, and SOLIDWORKS raised File ▸ Open, which blocked every quit after it.

So:

  • helperSignIn is not a stall. The clocks pause from the helper's log until sldworks.exe appears (see the table above).
  • No re-invoke while this launch has a SOLIDWORKS. Any sldworks.exe outside the FIRST route's baseline belongs to this launch. When a route stalls, the census is read before anything is cleaned up. Before any retry, the supervisor watches for SWX_LAUNCH_RETRY_SETTLE_MS. If such a SOLIDWORKS is there:
    • nothing is killed and nothing is invoked;
    • launch_retry_declined {why, pid, createdAt} is ledgered;
    • the supervisor waits to attach to that SOLIDWORKS.
  • Each route invocation is a "verb" for the dialog census (launch.invoke, launch.retry), in flight until SWX_LAUNCH_DIALOG_GRACE_MS after it ends. A File ▸ Open with an EMPTY name that a route provably raised (proved absent after it began, no user input since) is cancelled by the supervisor itself, and by swx_start. It is ledgered as dialog_cancelled with by:"the launch supervisor". A launch never leaves the session blocked by its own dialog.

When SOLIDWORKS is up but COM will not attach

attach_budget expiring with sldworks.exe RUNNING is not a stall and the chain is not killed: the application is on the user's screen, probably at a 3DEXPERIENCE sign-in only they can answer. The machine stops with outcome:"sldworksUpNotAttached" and never tries another route — a second route there would put a second SOLIDWORKS on somebody's desktop.


3. THE KILL ALLOWLIST

launch::may_terminate, one pure function, four conditions, all required:

  1. the image is on TERMINABLE_IMAGES = CATSTART.exe, SWXDesktopLauncher.exe — and nothing else, ever;
  2. the image is not on NEVER_TERMINATE (SLDWORKS.exe, swCefSubProc.exe, explorer.exe) or NEVER_TERMINATE_PREFIXES (3DEXPERIENCELauncher, matched as a prefix so a renamed or versioned daemon cannot slip through);
  3. the creation time is KNOWN — an unreadable creation time is "not ours", never "probably ours";
  4. it was created at or after our own invoke (±1s of clock slack), so a chain the user started before we asked for anything is never in the list.

Every terminate is OpenProcess(PROCESS_TERMINATE) + TerminateProcess by pid, with the image name re-checked against a live census first (pids are reused). There is no image-name kill and no /T tree kill anywhere in the launch path.

Never kill the 3DEXPERIENCE launcher daemons

3DEXPERIENCELauncher.exe, 3DEXPERIENCELauncherSysTray.exe, 3DEXPERIENCELauncherService.exe are the Platform's own background services. The bridge must never terminate one:

  • they are not required for a launch — the successful launch on 2026-09-04 ran with no Backbone at all, so killing them buys nothing;
  • they carry the user's Platform session. Ending one is ending something that is not ours to end;
  • a real user launch rebuilds them. If they are genuinely broken, the fix is the user starting SOLIDWORKS from their own Start Menu — not an agent reaching for taskkill.

sldworks.exe is likewise never killed here. Ending the user's CAD session is swx_kill's job, and that verb is gated behind the process-level write opt-in, a reason and a confirm for exactly this reason.


4. In-flight idempotence

launch-state.json, next to com-host-created.json in the bridge's scratch root, holds the attempt (id, started-at, shortcut, reason, caller, route plan, current route, invoke time, chain pids, launcher log, stage, every route tried with what it cost) plus preferredRoute and a bounded history.

swx_start during an attempt that is unfinished, younger than 4 minutes and has live evidence (this process is supervising it, or its chain is alive, or sldworks.exe is up) returns:

{"success": true, "stillLaunching": true, "launchedNow": false,
 "stage": "runBegin", "ageSeconds": 7, "route": "explorer",
 "launch": { "...the whole attempt..." },
 "_hint": "NOTHING WAS LAUNCHED BY THIS CALL, deliberately: ..."}

and launches nothing. Two simultaneous swx_start calls are made safe by a compare-and-swap on the supervisor flag: exactly one can win, the other reports. The reason gate is checked AFTER the in-flight check, so polling with swx_start — which this bridge's own hint tells callers to do — is never refused for a missing reason.

The third condition is what keeps this honest rather than a timer: a state file left behind by a bridge that was killed mid-launch does not block the next launch forever.

swx_status carries the same launch block, plus a host-app half read without COM — which is the point, because the moment those numbers matter is the moment COM has stopped replying:

"solidworks": {"processId": 88556, "responding": false, "privateMemoryMB": 17465,
               "handles": 21770, "openDocs": 52, "hung": true,
               "exhausted": "sldworks.exe is holding 17465 MB ..."}

responding is IsHungAppWindow on the application's own top-level window, found by enumerating windows for its pid. null anywhere means UNKNOWN and is never rendered as a healthy value.


5. Honest hung / exhausted reporting

Code Raised when The hint says
host_app_hung IsHungAppWindow is TRUE and a COM call timed out only the user can end it, and why this bridge will not
host_app_exhausted NewDocument returned nothing, or private memory > 12 GB, or > 40 documents open close the bridge's own documents (swx_close_all_safely preserves them first); then a RESTART of SOLIDWORKS is the user's call; do not retry in a loop
host_app_com_unreachable sldworks.exe is running, COM will not attach, and it is neither hung nor exhausted probably a sign-in window; ask the user to look at their screen

Both hung signals are required, and an unknown is never a yes: a not-responding window mid-rebuild is not hung, and a COM timeout alone is usually a modal. Likewise exhaustion() returns None when it has no reading — an unreadable memory figure never manufactures a verdict.

swx_start never launches a second SOLIDWORKS. If sldworks.exe is running at all, it reports one of the three codes above instead. On 2026-09-03 the process was alive at 17.4 GB while COM attach failed, and the old bridge said "SOLIDWORKS is not running, and this edition cannot be started programmatically".

The empty-value audit

Every place a failed COM read could become a confident wrong fact:

Where Was Now
cmd_app_state raw GetDocuments().as_array() → documents: [] beside a valid processId; the server then pruned its whole ownership registry routed through documents(), which refuses an empty list GetDocumentCount contradicts (docs_enumeration_failed)
cmd_status.openDocs .unwrap_or(0) — reported 0 with 51 open GetDocumentCount is the authority; unreadable is null, never 0
cmd_check_recovery array length the count, same reason
cmd_close_all_documents array length before/after a DESTRUCTIVE call documents() both sides
cmd_import before/after array length; a collapse would import the file TWICE documents()
verbs3::ownership_context Err → (None, [], None) → prune wiped ownership prune_scratch is a no-op when the session is unknown
launchos::processes() — Err, never an empty census: "I could not look" and "nothing is running" lead to opposite decisions

6. Crash-reporter recovery

After Run Begin (before that there is no SOLIDWORKS to have crashed), the supervisor looks for a top-level window whose title reads like a crash reporter — and candidacy is not permission. It then asks ab, read-only, whether that window carries a control named "Restart SOLIDWORKS" (desktop_find_control). Only if ab says yes does it send ONE background desktop_ui_click targeting that button by accessible name, with a human-readable reason, and record it in the ledger as solidworks_crash_reporter_restarted.

  • once per attempt, guarded by persisted state;
  • the button is the identification, not the title: a title is a guess and a click is an act;
  • nothing else is ever clicked. No save prompts, no sign-in, no "Send report to Dassault", no OK on a dialog we did not identify. The test keeps a "Save changes to Part1?" dialog on screen throughout and asserts nothing touched it.

7. Bug #5 — ownership across a respawn

Two independent records, neither derived from the other, and now BOTH on disk:

  • the com host's com-host-created.json (already persisted);
  • the server's owned-docs.json — new. Written on every remember/forget/rename/prune, loaded by Ctx::new, keyed by the SOLIDWORKS process id AND, where the OS can tell us, that process's creation time, so a recycled pid cannot inherit another session's claims. When either start time is unknown, nothing is dropped on a guess: the reconcile can only ever REMOVE a claim, never add one.

Plus the other half, which was the actual field mechanism: prune_scratch(None, …) used to drop every entry, because every entry fails d.session == None. One unanswerable app_state therefore disowned everything the bridge had created. It is now a no-op when the session is unknown.

swx_stop clears both records — the session ended, its untitled numbering restarts at Part1, and no remembered title can be trusted.

The mock backend now also persists the simulated SOLIDWORKS session across com-host respawns, because a real one survives them. Without that the mock modelled a SOLIDWORKS that closes every document when the bridge's sidecar restarts, which made this whole class of bug untestable: the documents were gone, so no verb ever reached the ownership check.


8. Tests

cargo test — 186 tests, all green (97 unit + 14 adversary + 8 adversary2 + 4 adversary3 + 3 adversary4 + 28 e2e + 21 launch + 11 protocol), x86_64-pc-windows-gnu cross-compile clean.

tests/launch.rs (12) drives a simulated machine (SWX_LAUNCH_FAKE_DIR, src/launchos.rs::FakeOs) whose behaviour is a written-down scenario, and measures what the bridge DID on it — which processes it ended (terminated.jsonl), how many launcher invocations it made (invocations.jsonl), what it left running — never what it returned.

Test Proves
a_stalled_route_is_cleaned_up_and_the_next_route_takes_over 2 invocations; ONLY our chain's 2 pids terminated; daemons, the user's pre-existing chain and sldworks.exe untouched
the_route_that_worked_is_remembered_and_tried_first_next_time preferredRoute persisted, and a DIFFERENT server process attaches on ONE invocation with nothing to clean up
three_calls_during_a_launch_start_exactly_zero_extra_launchers 5 swx_start calls → 1 invocation, 0 terminations; a bare call needs no reason
every_route_exhausted_is_reported_honestly_and_leaves_nothing_of_ours_running 4 routes, 8 processes cleaned up, ASK THE USER, and a finished attempt does not lock out the next one
solidworks_up_but_not_attaching_stops_the_machine_instead_of_launching_another sldworks.exe up + no COM: outcome sldworksUpNotAttached, no second route, chain NOT killed, route not remembered
the_crash_reporters_restart_button_is_clicked_once_and_nothing_else_is exactly 1 click, by name, on the right hwnd; no window closed, no shell command, the Save-changes dialog untouched
a_window_that_merely_looks_like_a_crash_reporter_is_never_clicked the button gate, not the title
a_hung_solidworks_is_reported_as_hung_and_a_second_one_is_never_launched host_app_hung, 0 invocations, responding:false read without COM
an_exhausted_solidworks_is_named_as_exhausted_rather_than_blamed_on_a_template host_app_exhausted quoting 17465 MB, 0 invocations
a_new_document_that_comes_back_empty_is_exhaustion_not_a_template_fault the NewDocument-returns-nothing signature
ownership_survives_an_unanswerable_probe_and_a_com_host_respawn ownership survives a blind window, a real kill -9 of the com host, and a server restart
a_bridge_that_cannot_see_the_machine_still_launches_rather_than_refusing an unavailable census is UNKNOWN, not "not running"

Unit tests cover the pure rules: the kill list against daemons / pre-existing chains / unknown creation times, the route plan, is_hung, exhaustion, the crash-reporter matcher, stage ordering, the persisted registry round-trip and pid-reuse reconcile, and the simulator itself.


8a. Round 2 (0.4.1) — what an adversary found in the above

An adversarial review of 0.4.0 wrote 14 failing tests, each naming a property this document claimed and showing the code did not have it. All 14 now pass, and tests/adversary.rs is part of the suite. What changed:

The kill list was scoped by CLOCK, not by identity

Two defects in one rule, both proved by test:

  • a chain the user started while ours was in flight was swept up with ours (pids 70003/70004). Twenty seconds of a screen doing nothing is exactly when somebody reaches for their own Start Menu, and clean_up_chain re-derived its list from the census — ignoring the Attempt.chain it had carefully recorded;
  • the "±1s of clock slack" was applied to the PROCESS's age, so a chain created a second before our invoke was terminable.

Now: a baseline census is taken immediately before the invoke; our chain is what appeared after it and descends from our own CATSTART (chain_candidates, a parent-pid fixed point); the cleanup kills only what is recorded, re-confirmed as (pid, image, creation time); and the kill itself re-reads GetProcessTimes on the open handle immediately before TerminateProcess, so a pid reused between the census and the kill cannot be hit. There is no backward slack anywhere — instead LaunchOs::now() reads GetSystemTimeAsFileTime, the same clock the creation times come from, through the same conversion. "Did this launcher log appear after our invoke?" now prefers the launcher's own filename stamp (yyyyMMddHHmmss) over the filesystem's creation time, which can be absent, can be a copy time, and on Windows survives a delete-and-recreate.

documents() believed an unreadable count

count = GetDocumentCount.unwrap_or(-1); if docs.is_empty() && count <= 0 → believe empty. The guard written to close bug #5 had the same bug inside it: an unreadable count is -1, and -1 <= 0. The count is now an Option, an empty list is believed only when the count says Some(0), and an unreadable one retries then raises docs_enumeration_failed.

The crash-reporter click could be triggered by an echo

mentions_button stringified ab's whole reply and searched it for the button name — and the bridge sends that name in the request, so any reply echoing its query passed the gate with zero controls found. It now reads named fields only: an explicit found:true, or a control entry whose own name / automationId / text names the button. An empty list is a no; an unrecognised shape is a no.

Candidacy was title contains "solidworks" AND one of seven words, one of which was the bare word restart — so a browser tab about a SOLIDWORKS crash, an Outlook thread, and the Installation Manager's "restart required" were all probed. It now requires a phrase that says a crash happened and rejects a list of things that are demonstrably something else. On top of that, the window must be owned by the SOLIDWORKS process we are launching (pid match), the click is once per attempt and once per bridge process per sldworks pid, and

crash recovery now ships DISABLED (SWX_CRASH_RECOVERY=on arms it; launch.crashRecovery reports off). The reason is in §9.5: no reply from ab's desktop_find_control has ever been observed, so every rule above is written against a guessed shape. tools/launch-gate.py --probe-uia captures the real one, read-only, and clicks nothing.

Exhaustion flagged a healthy session

> 12 GB or > 40 documents was read off one wedged session (17.4 GB / 52 docs) and set 4 GB below it. The same machine the same week ran a healthy 13.1 GB session with 33 documents open — and swx_start told it to close its documents and have the user restart. Worse, exhausted_error relabelled every sidecar error as exhaustion whenever the session was large, so a template typo became "restart SOLIDWORKS".

Now: NewDocument returning nothing is the only sufficient signal — that is what exhaustion looks like from the API and what was actually observed. The compound path (exhaustion_confirmed) additionally needs not responding AND a real COM timeout AND memory rising across two reads. Memory is reported as memoryAdvisory, above 17.8 GB, worded as an observation; privateMemoryMB and workingSetMB are reported side by side, because the two diverge by gigabytes on a paged-out CAD session.

The bridge's own long verb was reported as a hung SOLIDWORKS

com_timed_out = com.error.is_some() || snap.busy — and the com host is busy exactly when this bridge is running a long verb, which is exactly when SOLIDWORKS stops pumping its message loop. Two "independent" signals from one cause. is_hung now returns Option<bool>: com_timed_out means a COM call exceeded its budget, a busy host is None, and hung:null ships with a hungNote saying why.

A stale attempt was reported in flight forever

in_flight accepted any sldworks.exe as evidence that OUR attempt was alive, so a bridge restarted while the user's SOLIDWORKS sat at a sign-in told every caller to poll a supervisor that did not exist. The attempt now records its supervising bridge pid; on load, an attempt whose supervisor is not us and is not running is marked outcome:"abandoned" on the spot, and the only liveness evidence accepted is our own chain's pids.

A cleanup that could not run looked clean

killed: [] from a failed census is indistinguishable from "there was nothing to kill". RouteTry now carries cleanupFailed, cleanupError and chainLeftRunning, the attempt surfaces them, and the machine stops trying further routes once a cleanup could not be performed — going on would start another chain beside one it cannot account for.

Smaller, and all the same shape

  • a route that started nothing was described as "reached SecurityContext"; the note now branches on whether a launcher log for our invoke ever appeared.
  • finish("attached") memorised the current route as preferredRoute even when the attach came from the user launching SOLIDWORKS mid-flight. It now requires Run Begin in OUR OWN log — the evidence that our route did the work.
  • a retry after a failure re-swept all four routes, starting four fresh chains. There is now a backoff (host_app_launch_backoff, growing with consecutive failures) and a retry tries one route, not the plan.
  • host_app_health took the first sldworks.exe in the census, so a zombie supplied the numbers and the verdict. All are reported; the one described is the one COM is attached to, else the newest, and reportedProcessBasis says which rule was used.
  • owned-docs.json was a plain fs::write on every mutation (hundreds of times per build) and an unparseable file was silently an empty registry — bug #5's end state by another road. Writes are now temp-file + rename with a .bak; Registry::{Absent, Loaded, Unreadable} are three different answers and the corrupt case is loud on stdout and stderr and keeps the evidence as .bad. launch-state.json got the same treatment, and swx_status no longer rewrites it on every poll.
  • prune_scratch compared paths byte-exact while owns_doc used same_path, so a document SOLIDWORKS reported under a different casing was pruned out of the registry that would have granted it. Both now call verbs::doc_matches.
  • component_count's .unwrap_or(0) was the success test for swx_add_component: a failed enumeration made a successful insert look failed and the method ladder inserted again. It is now an Option, and an unreadable count stops the ladder with component_count_failed.

The remaining empty→confident collapses

Where Was Now
cmd_status.openDocs as_array().len() fallback GetDocumentCount only; null when unreadable
cmd_check_recovery unwrap_or(0) null
GetInterferenceCount unwrap_or(0) — a false all-clear on a clearance report null, plus interference_enumeration_failed when the count and the list disagree
swx_stop's unsaved guard unwrap_or(0) waved the quit through unsaved_unknown; an unanswerable question is not permission
swx_state.blockingDialogs unwrap_or_default() → [] = "nothing in the way" null + blockingDialogsKnown + blockingDialogsError
swx_check_recovery ab unreachable → recoveryPending:false null + recoveryKnown
swx_close_window ab unreachable → window_not_attributed window_list_unavailable, so the caller looks at the right problem

8b. Round 3 (0.4.2) — the attribution law

A second adversarial pass wrote 8 more failing tests (tests/adversary2.rs). The verdict was blunt and correct: 0.4.1 still terminated a user-started chain. Proved on a run where our own route started nothing at all — the bridge adopted pids 70003/70004, which belonged to the user's Start-Menu launch, and ended them.

THE LAW: no handle, no kill

Round 2 replaced "kill everything terminable younger than our invoke" with "kill what descends from our own root". It did not say how the ROOT was known, and the answer was a guess:

let is_root = p.is("CATSTART.exe") && ids.is_empty();   // 0.4.1

The first CATSTART to appear after our invoke, in census order, is ours. Three things made that indefensible:

  • census order is roughly pid order, and a pid does not record who asked for it;
  • parentage could not break the tie, because WATCHED_IMAGES contained no shells — so the root's parent link always dangled and descends was false;
  • on the explorer route, our CATSTART and a Start-Menu CATSTART have literally the same parent: the user's explorer.exe.

So the rule is now stated as a law, in launch::AttributionRule:

A process may be terminated by this bridge only if its membership of our launcher chain descends, through parent links, from a root the INVOKE ITSELF handed back. Nothing found by searching the machine afterwards is ever adopted.

Route Attribution May clean up?
shellExecute SEE_MASK_NOCLOSEPROCESS → hProcess → GetProcessId + GetProcessTimes yes
powershell Start-Process -PassThru prints the pid; the child is waited on, and a non-zero exit or any stderr means the route started nothing yes
explorer none — the shell forwards the link to an existing process and returns nothing never
cmdStart none — start returns as soon as it has handed off never

Consequences, all of them deliberate:

  • default route order is now shellExecute → powershell → explorer → cmdStart — attributable first. The unattributable two are kept because they may be the routes that actually work, which is still unmeasured.
  • chain_candidates is a descendant walk only. With no seeds it returns empty; it can no longer invent a root. Seeds are used to find CHILDREN, never to re-adopt a member by its number — attributed_chain re-confirms every recorded member with ProcInfo::same_process, because CATSTART exits about three seconds into a healthy launch and its number is then free for the rest of a four-minute watch.
  • an attribution with no creation time is not an identity and is refused: the bridge does not terminate a bare number.
  • explorer.exe / cmd.exe / powershell.exe joined the census as parent links only, and the never-terminate list beside sldworks.exe and 3DEXPERIENCELauncher*.
  • a stall on an unattributable route is recorded as cleanup:"notAttributable" and says, in the response, that a chain may remain which the bridge cannot identify and the user may end from Task Manager.

When the machine stops

A route that started nothing leaves nothing behind, so the machine goes on — that is what keeps the unattributable routes worth trying. A route that started something it cannot account for stops the machine, because another route would put a second chain beside one nobody can find:

Cleanup outcome Continue?
cleaned / nothingToClean yes
notAttributable, no launcher log ever appeared yes — nothing was started
notAttributable, a chain WAS started no
failed (census down, or a terminate refused) no

The baseline-census hole is closed with it: a baseline that could not be taken is Cleanup::blind() — cleanupFailed:true, never "nothing to clean". The route is still WATCHED (attach needs no census, and refusing to watch a launch we just started is abandonment, not caution); it simply may not be followed by another.

The log evidence was a fabrication waiting to happen

Two defects, either of which would have made the gate's central measurement meaningless:

  • launcher_logs().unwrap_or_default() turned a failed directory listing into "there were no logs", and is_newer_than answered (Some(_), None) => true — any log is ours. Five of the nine logs on the real machine contain Run Begin, so the bridge would have read a launch from three days ago, jumped straight to runBegin, skipped stall detection, and two minutes later reported a stall that never happened. A failed listing is now UNKNOWN: no log is read, no stage transitions, and the stall note says so instead of claiming nothing started. A verifiably empty directory is a different fact and is handled where we know the listing succeeded.
  • is_newer_than short-circuited on the local-time filename stamp and never consulted the filesystem. For the one hour a year a local clock repeats, our own log carries a LOWER stamp than one written before it. It is now stamp_newer || created_newer — two independent clocks, either may answer yes — and a stamp the file's own UTC creation time contradicts by more than a timezone (26 h), or that is more than five minutes in the future, is untrusted: one log copied from another machine would otherwise poison a directory nothing prunes.

Honesty and coupling

  • com_timed_out was decided by e.message.contains("did not answer") in two files, a copy of a sentence built in a third. SidecarError and HostError now carry an explicit timed_out flag over the wire, and an unrecognised com failure yields hung: null — a crashed com host says nothing about whether SOLIDWORKS is wedged.
  • swx_interference's hint now says what count: null means, in the terms that matter: null is falsy in every language this bridge's callers use, so if (!r.count) turns "I do not know" into "all clear" on a clearance report somebody builds a physical part from. Branch on countKnown.
  • the mock can now emit all three new nulls (interference count, check_recovery's open-document count, component_count), so those shapes are exercised end to end rather than merely produced.
  • write_atomic retries the rename three times on ACCESS_DENIED — an antivirus scanner or the search indexer holds a brief handle on a file it has just seen created, and this one is rewritten hundreds of times per build — and returns whether the write landed; a failure is logged, not swallowed.
  • an unreadable owned-docs.json now falls back to the .bak generation, loudly. It is a registry this bridge wrote one write ago: at worst it is missing the newest claim, where owning nothing refuses every document created before the restart, which is bug #5 itself.

Crash recovery stays OFF

Unchanged from round 2, and now with a second reason: the crash reporter is a separate process from sldworks.exe (Windows Error Reporting hosts it), so the pid attribution added in round 2 must accept a child or otherwise related process rather than an exact match. Writing that rule needs the real relationship, which nobody has looked at. tools/launch-gate.py --probe-uia now captures both the desktop_find_control reply shape and every crash-reporter-looking window's owner pid beside sldworks.exe's.

8c. Round 4 (0.4.3) — the law, applied to evidence

Adversary pass 3 found no consent-affecting defect: it could not make the bridge terminate a process it did not start. The law held at the mechanism, and the_users_own_launcher_chain_is_still_never_terminated is pinned as a regression test so it cannot quietly re-open.

What it found instead is the same question one level up. The bridge no longer adopts a stranger's processes — it still adopted a stranger's evidence.

A new log is not our log (H1)

watch_route attributed a launcher log on ONE test: is it newer than the newest log that existed before our invoke? That answers "this log is not pre-existing". It does not answer "we wrote it" — and nothing else was asked.

So on a run where our route started nothing (which this bridge detects, names, and rightly keeps going after), a launch the USER starts while we watch writes the log we then read as our own. Their Run Begin becomes our runBeginSeen, and finish("attached") — whose whole purpose since round 2 was to stop a mid-flight user launch teaching us the wrong lesson — then writes preferredRoute for a route that started nothing. Permanently, on that machine, first in the plan for every future cold start. It is also the first thing the gate would have measured, so the measurement would have been a fabrication.

The same law now governs the evidence:

A launcher log is ours only when OUR OWN attributed chain existed to have written it: an attribution exists, its root has been CONFIRMED alive at least once, and the root was created no later than the log.

On an unattributable route no log can be attributed at all, so run_begin_seen stays false there and preferredRoute is never written from it. A new log we cannot claim is reported as unattributedLog: true and ledgered — evidence that something started, no evidence whose it is.

The cost, stated plainly: if the launcher only ever comes up on explorer or cmdStart, the bridge will launch SOLIDWORKS successfully and still never learn that route. attached is proof (from COM) that SOLIDWORKS is up and usable; it is not proof that our route did it. The plan order then stays fixed, which is the right way round — a remembered route is acted on for ever.

A root we cannot see still carries its children (H2)

attributed_chain confirmed the root by finding it in the census — and the census holds only the watched images. ShellExecuteExW hands back whatever the shell created for the .lnk, and no real reply has ever been observed. If that process is a shim, a wrapper, or a future Platform build rather than CATSTART.exe, the root was never seeded, the walk had nothing to descend from, and the real CATSTART it started — whose ppid is the handle-proven pid, right there in the census — was never attributed. The stall was then reported as attributed:true, cleanup:"nothingToClean": a CLEAN recovery, with a chain of ours still running.

A handle-proven root is proof of lineage whether or not the root is itself something we may terminate. It now seeds the walk unconditionally — unless the machine CONTRADICTS it (a census entry with that pid and a different creation time, i.e. the number has been reused), in which case the lineage is void and its apparent children are not adopted.

And "never confirmed" is now its own outcome. An attributed route whose root and descendants were never once seen reports cleanup:"notConfirmed", cleanupFailed:true, chainMayRemain:true and stops the machine — that is a failure to account for what we started, not a clean "nothing to kill".

cleanup Means Machine continues?
cleaned our chain was found and ended yes
nothingToClean attributed, confirmed, nothing left running yes
notAttributable the route cannot say what it started only if NO log appeared at all
notConfirmed attributed, and we never saw the root or a descendant no
failed census down, or a terminate refused no

Also in this round

  • The simulator can now model "attributable route, chain started, NO handle" ("attributable": false per route) — the shape ShellExecuteExW on a .lnk is most likely to produce, since the shell may hand the link to an already-running process by DDE and return nothing. And "invisibleChain": true models a chain the census never shows, which is how notConfirmed is tested.
  • A real concurrency bug, found by the suite flaking. write_atomic's temp file was named from the pid alone — one name shared by every HTTP worker thread, and the ownership registry is rewritten from several of them. Two concurrent writes raced on that one file: both wrote it, one renamed it away, the other's rename failed with "no such file", and a half-written file could have been renamed into place. The name is now unique per write.
  • The gate answers "which world are we in?" first (--which-world): does route shellExecute come back with a rootPid on this machine? That single answer decides whether this bridge can ever clean up after itself here, and everything else in the run is read differently depending on it. The gate also prints a per-route table — invoked-at, the log it matched (or unattributed), Run Begin, sldworks, attach, cleanup kind.

8d. Round 5 (0.4.4) — evidence bookkeeping

Adversary pass 4 found no consent-affecting defect either: the process-side law has now survived four rounds of direct attack. What it found is the last version of the same mistake — a test that is NECESSARY and not SUFFICIENT.

"Our chain existed before this log" is true of every log after that (H1)

Round 4's rule was root_confirmed && root.created_at <= written. That is true of every log written after our chain started, the user's included — and launcher_logs() is newest-first while the selection was a bare find, so when a second launch is running the bridge preferentially read THEIRS. Our own route's log stops at SecurityContext:; theirs has Run Begin in it; the bridge reported that it had seen Run Begin, and the round-3 run_begin_seen guard on the route memory was defeated for the second time.

The log is now chosen by three tests, in order of how much they prove (choose_our_log):

  1. AUTHORSHIP. The launcher stamps the writing thread's id on every line (17:23:09 [116244] info Run Begin). If thread_owner(tid) resolves to a process in OUR OWN chain, the log is ours — positively. A miss proves nothing, because threads exit.
  2. SOLE CANDIDATE, bounded from both sides. Exactly one log post-dates the baseline, our chain existed before it, and no launcher chain that is not ours was seen alive in the meantime (foreignChainSeen).
  3. Otherwise: none. Two candidates and no authorship proof is unattributedLog. Never "pick the newest".

Once a log is ours it STAYS ours — re-selecting every poll is how a log that appears later displaces the one we were reading.

Every per-route fact resets per route (M1)

root_confirmed and unattributed_log did not, with two consequences: notConfirmed was unreachable after route 1 (so round 4's H2 was quietly restored for routes 2-4), and the log gate's root-confirmed half was pre-satisfied by an EARLIER route's chain. The simulator gained "rootVisible": false so the invisible-root case has a test of its own.

A per-route table whose rows are all the same number (G1)

launcherLog, runBeginSeen and unattributedLog lived only on the ATTEMPT, and the gate's per-route loop read all three from there — printing the last route's evidence against every row. RouteTry now carries its own launcherLog / runBeginSeen / unattributedLog / rootPid / rootConfirmed / cleanup, copied at the end of the route while they still describe it, and the gate prints from routesTried only. The winning route is recorded too, so the table's most important row is no longer missing.

The gate could report PASS without seeing the machine (G2)

verb() returned {} and ps() returned "" on failure — the same empty-to-confident collapse the bridge itself was rewritten four times to stop making, on the Python side, in the tool whose whole job is measurement. Both now raise Unreadable, and the gate exits 3 with "could not ask, not nothing found". --which-world is renamed --probe-handle-launches so the name says it LAUNCHES, and ps() documents that shell_execute is ab's general escape hatch whose only assurance is reading the script (this one runs a process census, reads the launcher's own log directory, and curls the bridge's loopback port — no kill, no write outside $env:TEMP).

Also

  • lastRouteBeforeAttach records which route was in flight when COM attached, with "proof": false — an ordering hint for a human. It never satisfies run_begin_seen and is never promoted to preferredRoute. Added only after H1 was fixed, so it cannot be persisted from a corrupted signal.
  • finish() is idempotent — a second call was bumping consecutive_failures again, doubling the backoff for one attempt.
  • ledger.jsonl, timings.json, settings.json and the com host's com-host-created.json now go through write_atomic too. The ledger is the one artefact that outlives the bridge; a truncating write could have taken the whole audit log rather than the last line. The .bak copy is staged through a unique name and renamed into place, so two writers cannot leave a torn backup — which matters because the backup is what owned::restore falls back to.
  • the descendant walk requires child.created_at >= root.created_at: a process that already existed when ours started is not our child, whatever its ppid points at.
  • the created().or(modified()) conflation in the log listing is now commented where it is: modified() on a live log is "a moment ago" whatever the launch is doing, so it is a last resort for filesystems that record no creation time.

9. STILL UNPROVEN until the live gate runs

SETTLED LIVE, 2026-09-30 (see "Live proof 2"): the full gate passed every launch claim through the bridge ab hosts — shellExecute, attributed, Run Begin at 28.8 s, the helper sign-in step passed without a sign-in, COM attached at 139.8 s. That answers items 1 and 2 below for this machine. Items 3–7 are unchanged: the run did not need to kill a stalled chain, did not run --probe-uia, and did not meet a wedged SOLIDWORKS.

Nothing below had touched a real SOLIDWORKS when it was written. tools/launch-gate.py is written to settle every line of it, and must only be run when Drew says SOLIDWORKS is down.

  1. Whether any of shellExecute / powershell / explorer actually reaches Run Begin. The stall hypothesis (startup context / show state) is inference from 5 stalls and N interactive successes, not a proven cause. If all four routes stall, the machine will say so honestly (exhausted, ask the user) — which is the correct behaviour, and would tell us the hypothesis is wrong.
  2. The route ordering. It is now decided by ATTRIBUTABILITY, not by which mechanism is most likely to work — shellExecute and powershell lead because they can say what they started. If the gate shows the launcher only comes up on explorer or cmdStart, the bridge will launch successfully and still refuse to clean up after those routes; that is the trade, and it is the right way round.
  3. That killing a stalled CATSTART/SWXDesktopLauncher is clean — that it leaves no dialog, no half-registered session and no Platform complaint.
  4. Creation-time resolution. GetProcessTimes is second-resolution, and there is no longer any slack: a chain created in the same second as our invoke passes, one created a second earlier does not. If a real launch is ever missed for that reason it shows up as an empty chainPids and a cleanup with nothing to do — visible, and safe in the direction that matters. The ppid walk and the baseline diff are what actually carry the attribution.
  5. ab's desktop_find_control reply shape has never been seen, and the whole click decision is read from it. Crash recovery therefore ships disabled; tools/launch-gate.py --probe-uia captures a real reply, read-only, and until then the bridge reports the crash reporter and does nothing about it. The button is matched as "Restart SOLIDWORKS" (a contains) because Drew reported the label as "Restart SOLIDWORKS Design"; SWX_CRASH_REPORTER_TITLE widens the window matcher without a rebuild.
  6. IsHungAppWindow against a genuinely wedged SOLIDWORKS, and the PrivateUsage figure against Task Manager's.
  7. The desktop (Com) launch route still has no in-flight guard of its own. It is synchronous inside the com host, so concurrent calls queue on the request mutex and the second one attaches — but that is a property of the mutex, not something this release tested.

10. Running the gate

tools/launch-gate.py --baseline   # read-only: print the machine's baseline
tools/launch-gate.py              # the full gate — STARTS SOLIDWORKS

It never stops, kills or foregrounds anything. A failed probe is reported as UNKNOWN and the claim it would have supported is marked NOT PROVEN, rather than passing by default.

11. What the gate will tell us — one screen

Run tools/launch-gate.py with SOLIDWORKS down. It exits 0 on pass, 1 on a failed claim, 2 if SOLIDWORKS is already running, and 3 if any probe could not be read — the last is 'could not ask', and nothing below it is proven. Six questions, in the order the run answers them, and what each answer means:

# Question If YES If NO
1 Does shellExecute hand back a rootPid? (--which-world) the bridge can clean up after itself on this machine; the whole attribution design is live it never terminates anything here: it stops after one route and reports that a chain may remain. Designed behaviour, but stall recovery becomes reporting only
2 Does any route reach Run Begin? the stall hypothesis (startup context / show state) is confirmed, and route order can be tuned to the winner all four stall: the hypothesis is WRONG, the cause is elsewhere, and the honest answer is the one the bridge already gives — ask the user to launch it
3 Which route wins? preferredRoute is written and the next cold start costs one invocation if the winner is explorer or cmdStart, the launch works and is NEVER remembered (§8c) — expect the same fallback cost every time
4 Exactly one new chain, and three no-op swx_start calls during flight? in-flight idempotence holds on the real machine the four-chain failure is not fixed; stop and re-open failure mode B
5 Is every pre-existing process still alive at the end? the kill allowlist and the attribution law hold under real timing a consent failure: stop, do not deploy, re-open round 1
6 What shape does desktop_find_control really return, and what pid owns the crash reporter? (--probe-uia) mentions_button can be written against a real reply and crash recovery can be armed it stays off, which is where it is today

Timings the run prints, all measured rather than assumed: chain-appeared, Run Begin, sldworks.exe, COM attach — plus, per route, the log it matched (or unattributed) and the cleanup kind.

What the gate CANNOT tell us, and must not be read as telling us: whether the launcher stalls for the reason we think (it can only show that a route works or does not), and whether crash recovery is safe (that needs a real crash, and one has not been reproduced on demand).

Live proof 1 — ownership survives a com-host respawn (2026-09-08, 0.4.4 on Drew's laptop)

Sequence, measured by effect on a real SOLIDWORKS Connected session (pid 107688, user-launched, empty):

step call effect
deploy 0.4.4 installed over the dev bridge installed bytes 1,601,024 = built; bridgeVersion 0.4.4, userWrites disabled, crashRecovery off
create swx_new_document {type:part} Part1 open, unsaved, openDocs 1
respawn process_kill of the bridge's own com host (pid 15640) new host pid 73008, still attached, openDocs 1
close swx_close_doc {targetDocument:"Part1", save:false} in the DEFAULT posture success:true, closed:"Part1", openDocs 0

On 0.3.3 the same close after a respawn was refused with "this bridge did not create it" (bug #5/#12), because the in-memory registry died with the host and a null document list pruned what was left. 0.4.4 keeps the registry in owned-docs.json keyed to the SOLIDWORKS process and treats an unreadable list as unknown.

Still unproven live: the launch routes (needs SOLIDWORKS closed), and a graceful quit — the Process.CloseMainWindow() experiment was blocked by the agent-side auto-mode classifier, see docs/QUIT-INVESTIGATION.md.

Live proof 2 — every launch claim, through the bridge ab hosts (2026-09-30, 0.8.9)

adom-bridge 2.1.163 auto-installed the released 0.8.9 into bridges-cache\solidworks and routed every swx_* verb to it; the gates reached it through adom-bridge --target adom_drew2 <verb>, not through a side-loaded copy (that route is dead — see below).

gate result
launch (tools/launch-gate.py) PASSED every launch claim: route shellExecute, attributed, Run Begin 28.8 s, helper sign-in step passed without a sign-in, COM attached 139.8 s
ownership measurement PASSED both halves: a pathless Part2 preserved by ownership (25,959 bytes, verified, listed from the manifest); Drew's saved R_0603_1608Metric_1.step-1.sldprt closed with its original untouched — 32,311 bytes and the same mtime after the sweep
drain (tools/drain-gate.py) 5 / 5
quit (tools/quit-gate.py) PASSED at rung (a): process gone 29.5 s, servers reaped, nothing on screen, no crash reporter, rungs reported, SOLIDWORKS relaunched by the bridge 54.7 s later (docs/QUIT-INVESTIGATION.md §5.14)

What the same day found wrong — SEV-1, fixed in 0.9.0. bridges-cache\solidworks was CREATED 2026-09-18 09:42, by ab's auto-install of 0.8.9: the updater re-extracts the whole directory, and that directory was also where this bridge kept its data. Every recovery folder from 2026-09-17, the ledger, the ownership registry and the scratch documents were gone. Only test documents were in them. Since 0.9.0 the data root is %LOCALAPPDATA%\Adom Bridge\solidworks-data\ — a sibling of the install cache, never inside it; the first start migrates whatever is still in the old location (copied, verified byte for byte, originals left where they were, each file once); swx_recover_list still reads the old location and marks what it finds there atRiskFromUpdate; and a sweep refuses (recovery_inside_install_dir) — before writing or closing anything — if the recovery folder would land under the install directory or the install cache, however the path is spelled. One limit, stated rather than hidden: the update that installs 0.9.0 re-extracts the old directory BEFORE 0.9.0 first runs, so on a machine ab updates from 0.8.9 the migration will most likely find nothing — whatever 0.8.9 wrote there after 2026-09-18 is lost at the moment of that install. The move is what protects every update after it.

And one level up — SEV-1, conditional, moved again in 0.9.1 (adversary pass 39). 0.9.0's Adom Bridge\solidworks-data\ was out of the directory ab re-extracts on every BRIDGE update, and INTO the directory ab's own INSTALLER owns. The facts, kept apart by how much they are worth:

Source
The bridge cache Adom Bridge\bridges-cache\solidworks\ is re-extracted by a bridge update measured 2026-09-30 (created 2026-09-18 09:42 by the 0.8.9 auto-install; the 09-17 recovery folders gone)
ab "installs to %LOCALAPPDATA%\Adom Bridge\" ab's docs (adom-bridge-installer) — a claim
"The uninstaller lives at %LOCALAPPDATA%\Adom Bridge\uninstall.exe" ab's docs — a claim
"ab removes its own bridges-cache copy of you" ab's docs (adom-bridge-sdk-manifest) — a claim
What an ab uninstall, a repair, or "delete the Adom Bridge folder and reinstall" does to anything ELSE in Adom Bridge\ documented nowhere, measured never

The recovery folder is the only copy of unsaved work, and its survival cannot hang on the last row — that is the shape of the measured SEV-1, one level up. Measuring one ab uninstall would settle it for one ab version; it would not settle it for the next. So the data root moved to a SIBLING of Adom Bridge\ that nothing but this bridge writes, and whose only deleter is a person: %LOCALAPPDATA%\adom-solidworks-bridge\ (%USERPROFILE%\adom-solidworks-bridge\ when LOCALAPPDATA is missing; and if neither exists, the temp-directory fallback makes every sweep refuse with data_root_is_temp). Both earlier locations are now LEGACY ROOTS, migrated oldest first under the same rules, plus one the adversary found missing (sev-2): a legacy file is recorded by (root, path, size, mtime, sha256 of the copy), and one that CHANGES after its copy — a rollback appending to the old ledger, rewriting the old ownership registry — is copied again beside the first copy (<name>.legacy-<stamp>), never over it, and reported changedSinceCopy in the record, the ledger and swx_recover_list. A sweep refuses (recovery_inside_install_dir) if its recovery folder would land under the install directory, ab's own directory, or either legacy root. 0.9.0's data is outside the bridge cache, so a BRIDGE update should leave it for 0.9.1 to migrate — inferred from where ab extracts, not measured.

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. Dev testing is a PRERELEASE install through ab, or a renamed exe reached with SWX_BRIDGE_URL (the gates' escape hatch to a loopback /command URL). The mechanism is now MEASURED (ab's own log, 2026-10-01): [ab 2026-09-30 17:02:12] REAPED 1 instance(s) [(193004, 24695)] as duplicates; kept pid 150952 on the assigned port 49861 (three times), and [ab 2026-10-01 10:30:49] REAPED by ab: applying the update to 0.9.2 - reaped so the next call respawns on the new code. ab reaps any bridge process bound to a bridge port that is not the instance it tracks; a dev copy survives only by not looking like one (a different image name, bound elsewhere) or by being installed THROUGH ab as the tracked instance — which of those suits dev testing is still to be tried. And the version ab hosts is whatever the PUBLISHED manifest says, so a release IS a deploy to every machine: there is no staging step between tools/release.sh --publish and Drew's desktop running the new build.

The rule this project keeps having to learn: unit-tested is not wired

Five guards in this bridge have shipped unit-tested and with no production caller. Three of them were about attribution — the part of the system whose entire job is to decide what this bridge may touch.

The most recent, identity_matches_attribution (0.6.7), is the clearest case. The function was written, unit-tested, described in a commit message as "wired into swx_stop", and reviewed. It had zero callers: the edit that was supposed to insert the call was in a script that aborted partway through on an unrelated assertion, and nobody checked the file afterwards. Every test was green, because every test tested the function.

A unit test proves a function behaves. It says nothing about whether anything calls it. The two facts are independent, and only one of them was being checked.

So, as of 0.6.8, two rules — both enforced mechanically rather than remembered:

  1. A guard ships with a verb-level test that reaches it. Not a test of the function: a test that drives the VERB and asserts on the verb's response. The check is a mutation — delete the CALL SITE, leaving the function and its unit tests untouched, and the verb-level test must fail. If it still passes, the test is not reaching the guard and the guard is not doing anything.
  2. tests/wiring.rs lists every guard and fails if any has no production caller. It is a text scan with #[cfg(test)] blocks stripped — crude, because the failure it catches is crude. Retiring a guard means removing its line from that list, which makes deleting a guard a decision somebody makes on purpose rather than a silent edit.

The audit that produced the list found one unwired guard (identity_matches_attribution); hwnd_still_owned's only caller is inside #[cfg(windows)] code, which is a production caller and counts.

Live proof 3 — attached is not ready (2026-10-01, hosted 0.9.2)

ab auto-installed 0.9.2 at 10:30 local, straight from 0.8.9: the bridge cache was re-created, and 0.9.2 created its data root %LOCALAPPDATA%\adom-solidworks-bridge\ at 10:43 — it now holds the ledger, the ownership registry and a recovery folder (Adom Bridge\solidworks-data never existed on that machine). The 0.9.3 update is therefore the FIRST measurement of "an ab update leaves our data root alone".

The crash. A warm launch: Run Begin 4.5 s, COM attach 29.7 s. Then swx_new_document (keepUnsaved) at attach + 8 s, swx_open of a saved part at +9 s, swx_close_all_safely at +12 s — and SOLIDWORKS CRASHED INTERNALLY. %APPDATA%\SOLIDWORKS\SOLIDWORKS 2026\SolidWorksPerformance.log: LOAD_ADDIN lines still being written, then <DOC_OPEN> ".prtdot", <SWEXH> / <UNHANDLED> / </SWEXH>, <DOC_OPEN> ".sldprt", <3DXINITERROR>0 (the 3DEXPERIENCE add-in finished initialising AFTER the document commands), <PROCMEM> CMD:DocSave, <FT>1790869485, <RUNTIME>18, <SWERR_REPORT>. No Windows Application Error event; the journal stops at ActivateDoc2. The rescue HELD under the crash — Part1.SLDPRT, 26,129 bytes, verified, in the new data root, nothing closed — but the reply said dialog_census_unknown rather than "SOLIDWORKS is gone". A CONTROL launch with no document commands: no SWEXH, and the log gained <3DXLOAD_TIME>24000</3DXLOAD_TIME> when the platform load finished.

What 0.9.3 does about it.

A READINESS stage after attached swx_start says stage:"ready" only when SOLIDWORKS has finished starting, stage:"starting" (stillStarting:true) before; swx_status adds solidworks.ready and solidworks.readiness (what it is based on)
Two independent signals COM ISldWorks::StartupProcessCompleted (added to swx_com_eval's read allowlist; polled by the host's startup_state), and the performance log: a <3DXLOAD_TIME>/<3DXINITERROR> marker written AFTER the last LOAD_ADDIN line. Ready = every available signal agrees. With neither, a 60 s grace after first sight stands in and SAYS so (graceAfterFirstSeen); past five minutes it reports readinessTimedOut and stays starting — never promoted to ready by the clock
The gate swx_new_document, swx_open, swx_import, swx_save, swx_save_as, swx_run_script, swx_run_macro, swx_add_component, swx_close_doc, swx_close_all_documents, swx_close_all_safely, swx_close_import_temps, swx_reset_workspace, swx_shutdown and swx_stop refuse host_app_starting with retryAfterSeconds until ready; nothing is sent to SOLIDWORKS. A wiring GUARD
SOLIDWORKS dies mid-sweep host_app_gone: what was preserved (verified) and closed, and the session's crash markers from the performance log as crashEvidence — never dialog_census_unknown for a dead process
Lock files ~$<name> beside an open rescue is listed under lockFiles, never as a failed rescue

Not measured yet, and the next live run's first questions: whether StartupProcessCompleted turns true at the same moment the log's 3DEXPERIENCE marker appears; whether LOAD_ADDIN-then-marker holds on every start (the log format is undocumented — this is a heuristic read of one machine's log). The launch gate now waits for READY before any probe of its own.

0.9.5 — the gate inverted, the log bound to the session (adversary pass 42).

Gated by default Every verb waits for readiness unless it is on STARTUP_EXEMPT (16 verbs in 0.9.5, 17 from 0.9.11 with swx_user_presence, each with its reason in src/verbs.rs: the status/start/describe probes, the bridge-local and disk verbs, the window-census and window-message verbs, the screenshot, the badge, and swx_kill). The 15-name gate list had left swx_show_* (an open), swx_export and the ten swx_export_* (SaveAs3 — the crash log's CMD:DocSave), swx_com_eval, swx_set_parameter, swx_props_set, swx_rebuild_check, swx_bom, swx_pack_and_go, swx_activate_document and swx_set_preference reachable during add-in load. The gate also moved BEFORE the handler table (several arms return directly). tests/wiring.rs pins the exemption list name by name; a new verb is gated until someone adds it there
The log speaks only for THIS session A log last written before this process's creation time (census; the host's own word if there is no census) is notThisSession — never complete. Within a bound log the latest start is located by position (the format has no timestamps this bridge relies on)
COM alone is never enough on a machine that writes markers Measured: StartupProcessCompleted was TRUE before the marker. Once a marker has been seen in this machine's log (remembered in the data root, perflog-markers-seen), a session with no marker of its own is loading, and an untied log makes the session unverifiable — COM's true does not decide. Only an edition whose log has never held a marker lets COM decide alone
An add-in loaded ON DEMAND A LOAD_ADDIN after this session's marker is read as on-demand (ready) only when COM says the start completed, the log has been quiet for 60 s (ON_DEMAND_QUIET_SECS), and none of the LOAD_ADDIN lines after the marker names the 3DEXPERIENCE add-in (every start loads it — it writes the marker — so naming it means a start, possibly wedged). The verdict onDemandAddInAfterComplete carries this basis in why
The grace unlocks READS only state:"unverifiable" + readsAllowed:true after 60 s: catalog-read verbs run; every other verb refuses host_app_starting with startingUnverifiable:true ("cannot verify startup completed")
Quit during startup Under userAuthorized, swx_stop with maxRung c or d passes the gate with rungs a, b and e (COM) recorded skipped; (c) and (d) need neither COM nor readiness. Without that, the refusal names the way out (windowMessageRungs)

Residuals, stated: (1) a session that has written a non-LOAD_ADDIN line to the log but no LOAD_ADDIN yet would still show the previous session's marker after the last LOAD_ADDIN — the mtime binding cannot see that, because the lines carry no timestamps this bridge has measured; (2) the on-demand rule's "names the 3DEXPERIENCE add-in" test assumes the LOAD_ADDIN line names the add-in — if it does not, the rule degrades to quiet + COM; (3) the first start on a machine whose log has never held a marker lets COM decide. The next live run should capture: a LOAD_ADDIN line verbatim, whether a start writes a header line before its first LOAD_ADDIN, and an on-demand Toolbox load's lines.

Measured live on 0.9.5 (2026-10-01), and what 0.9.6 does with it. Launch gate PASSED (16/16); swx_docs was refused host_app_starting during startup; ownership sweep PASS; drain gate 5/5. The performance log:

Measured 0.9.6
The file is REWRITTEN at every start 1271 lines after the crash session, then 107 and 124 on the next starts; it begins ; SolidWorks generated log file - do not edit. / <VERSION> 6</VERSION> / <PROC_NUM> / <PROC_ARCH> / <OS> Only the text after the last header is read — residual (1) above is CLOSED: no earlier session's marker can be in it
mtime vs process start 13:34:49 vs 13:34:24 the mtime binding holds
A clean start writes a LOAD_ADDIN AFTER the marker <3DXINITERROR>0 at line 106, a LOAD_ADDIN at 107 Covered by the quiet-window rule: such a start reads READY once the log has been quiet for 60 s — a known cost of up to ~60 s on every start
Three add-ins carry "3DEXPERIENCE" SaveFolderTo3DEXPERIENCEAddin2.DLL "Batch Save to 3DEXPERIENCE", TaskPaneCacheExplorerAddin.DLL "3DEXPERIENCE files on This PC", SWK3DCompassAddin.dll "3DEXPERIENCE for WORKS" The on-demand rule keys on the platform DLL SWK3DCompassAddin.dll, never the substring — residual (2) closed

G3 (0.9.6): every gated verb's reply — success or refusal — carries readiness: {ready, state, basedOn, readsAllowed, evaluatedAt:"gate"} as the gate evaluated it (gatePassedFor:"windowMessageRungs" on a quit let through during startup), so a live run can prove no COM verb ran while starting.

Measured live on 0.9.6 (2026-10-01), and 0.9.7 (adversary pass 44). Launch gate PASSED (16/16, READY at 32.3 s); a swx_new_document reply carried readiness {ready:true, state:"ready", evaluatedAt:"gate"}; sweep PASS; drain 5/5; quit gate 11/11. On THIS start the marker <3DXINITERROR>0 was the LAST line of the log (105 of 105) — so a clean start sometimes writes a LOAD_ADDIN after its marker (the 0.9.5 measurement, line 106 → 107) and sometimes does not. Both are recorded; the rule handles both (complete at once, or on demand after the quiet window).

0.9.7
The marker-history flag is GONE perflog-markers-seen made two kinds of start wrong. F2: once set, a session that never loads the platform add-in (a user may disable SWK3DCompassAddin.dll — 24 s of the start) read loading forever. F3: before it was set, a log with no marker let COM decide alone — the first launch on any machine was the 10-01 crash window. The rule no longer depends on history
A log this session is still writing holds the start No marker, the log written less than 60 s ago: loading — COM's true does not promote it (measured: COM true before the marker)
The platform add-in loaded, its marker never came loading, however quiet the log (a dialog, a sign-in); past five minutes readinessTimedOut, and the refusal says WHY (the log's own reason)
The platform add-in never loaded Log quiet for 60 s and no LOAD_ADDIN naming SWK3DCompassAddin.dll: no marker is coming — platformAddInNotLoaded, ready only with COM's StartupProcessCompleted
The log's shape, recorded (F7) performanceLog.shape = {lengthNow, headersInTail, model} (rewrittenPerStart / appended / oneHeaderInTail / noHeader), plus lengthAtFirstSight and shrankSinceFirstSight (a rewrite shrinks it). An appended log is read from its last start header, like a rewritten one

Residual: an edition that writes no start header AND appends would still be read as one block; the mtime binding is then the only tie to the session.

Measured live on 0.9.7 (2026-10-01). Launch gate 16/16, READY at 16.7 s, the performance log reported as {shape:{headersInTail:1, lengthNow:6449, model:"rewrittenPerStart"}, lengthAtFirstSight:6449, shrankSinceFirstSight:false, verdict:"complete"} — the rewritten-per-start model confirmed by the bridge's own reading. Drain 5/5; quit gate 11/11. The one new finding is a dialog measurement (QUIT-INVESTIGATION §5.22): the 3DEXPERIENCE Connected notice read with its owner frame DISABLED for an instant while SOLIDWORKS was busy inside a sweep's close — 0.9.8 re-reads the owner before calling such a window a modal.

0.9.9 (adversary pass 45, the last of this cycle). V1: startup_complete returned the same "unavailable" for "no log" and "a log that exists but could not be read" (locked, an ACL, antivirus, a configured path that is missing), and unavailable + COM true was ready — COM alone, measured true before the marker. Now a log that exists or a configured path that is missing and cannot be read is unreadable: the session is unverifiable (reads after the grace; document verbs refuse, naming the log). Only a genuinely absent default log lets COM decide. V2: the ready latch was keyed by pid alone and lived as long as the server, so a SOLIDWORKS relaunched with a reused pid was born ready; it is now keyed by (pid, process creation time), like the session history and the quit ladder, and still survives a com-host restart.

Open — the single most valuable live measurement left (not built): poll StartupProcessCompleted once a second against the performance log on warm and cold starts, with the 3DEXPERIENCE add-in enabled and disabled, to measure the gap between COM true and the marker. Read-only, no document commands; it waits until Drew is no longer working in the session (0.9.9's presence guard is what tells the harness so).

MEASURED LOCK-OUT, 2026-10-01 on hosted 0.9.10, and 0.9.11. Drew's session — pid 26924, started 14:59:33, 34 documents open, nearly three hours old — had every gated verb refused host_app_starting: the update had respawned the bridge (the ready latch was gone) and the log read "loading … names the platform add-in". The log, verbatim: lines 93–101 <AD2> "LOAD_ADDIN" … (100 = "SWK3DCompassAddin.dll" "3DEXPERIENCE for WORKS"), 106 <3DXINITERROR>0, 107 <3DXLOAD_TIME>19000</3DXLOAD_TIME>, 114–120 <AD2> "UNLOAD_ADDIN" … (119 = the platform add-in UNLOADING), 124 <AD2> "LOAD_ADDIN" "sldfuncfeat.dll", then periodic <PROCMEM> CMD:AppRunTime lines (last write 15 min before the read). The bug: UNLOAD_ADDIN contains LOAD_ADDIN, so line 119 read as the platform add-in loading after the marker — "a start".

0.9.11
L1 — parse the token A load event is the quoted token "LOAD_ADDIN" (or the <LOAD_ADDIN> tag), never a substring; "UNLOAD_ADDIN" is its own event and never evidence of loading. The measured log is a fixture: it reads onDemandAddInAfterComplete → ready
L2 — a session cannot be starting for hours Process older than 10 min (SWX_LONG_RUNNING_SECS) AND COM StartupProcessCompleted true: "loading" holds only if a startup line changed within the quiet window; otherwise ready, basis longRunningSession
L3 — startup activity, not file activity An active session writes <PROCMEM> CMD:AppRunTime periodically, so the FILE is never quiet. The quiet window now counts only add-in loads and markers: the bridge remembers, per session, when those lines last changed (all were written at or before the file's mtime when first seen)
L4 — swx_user_presence is exempt It answers during a start or a wedge: foreground and last input from the process census; documents asked of COM only on a verified-ready session, otherwise documentsUnknown:true, which counts as present. The exemption list is 17 verbs
L5 — the latch survives a respawn A session seen ready is persisted in the data root (ready-sessions.json, by pid + creation time); a respawned bridge reads it (readyBeforeThisBridgeRestarted)

LIVE 2026-10-02 on 0.9.11 — the relaunch waited on the user's sign-in. After the quit, the relaunch sat at stage:"helperSignIn": window class DSYSysLoginPanelWebView2Class, title "Login | 3DEXPERIENCE ID | Dassault Systèmes", owned by SWXDesktopLauncher — handled correctly (nothing killed, deadlines paused), but tools/launch-gate.py reported five FAILs ("COM attached never"). 0.9.12: swx_start and swx_status carry waitingOn:"userSignIn" and signInWindow (the title) while the window is up, and the launch gate exits 4 — BLOCKED ON THE USER'S SIGN-IN, naming the window, instead of failing claims nobody could measure.

Live proof 4 — 0.9.3 on Drew's machine (2026-10-01)

Measured
An ab update leaves the data root alone ab updated the hosted bridge 0.9.2 → 0.9.3 on refresh_bridges; the listing of %LOCALAPPDATA%\adom-solidworks-bridge\ was BYTE-FOR-BYTE IDENTICAL before and after. The move of 0.9.0/0.9.1 is now proven on a real update, not just argued.
Readiness a document verb sent early was refused host_app_starting and created nothing; the session became READY 3.4 s after attach, when <3DXINITERROR> appeared after the last LOAD_ADDIN. StartupProcessCompleted was already TRUE before that marker — COM alone is not sufficient; requiring both signals is right.
Ownership sweep PASSED: Part1 preserved by ownership (26,171 bytes, verified, closed); Drew's part closedInPlace, untouched (32,311 bytes); lockFiles: []
Drain gate 5 / 5
New blocker see below — a NOTIFICATION was judged a modal and blocked every quit

The notification that blocked every quit. swx_stop → stop_blocked_by_dialog on a window the census called kind unknown, title "", text ["Title", "Description","Button1","Button2","Hyperlink Text 1","Hyperlink Text 2"], buttons []. Captured independently: hwnd 331370, owner process sldworks, class #32770, visible, style 0x94000044, exstyle 0x90080 (WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_CONTROLPARENT), rect 533,45–834,112 (301x67 px near the top of the frame), GW_OWNER = the main frame, ALL children class Static (visible: "Title", "Description"; hidden: the Button/Hyperlink statics). It appeared ~18 s after a document create + drain (not at idle before any document), persisted over 100 s, and for all 20 reads over 95 s its OWNER FRAME WAS ENABLED — not a modal. A background screenshot showed a green check and "Connected to OI000616940 — SOLIDWORKS is connected to your 3DEXPERIENCE platform": the 3DEXPERIENCE connection notice, custom-drawn, so the Static captions read were TEMPLATE PLACEHOLDERS, not its words. It blocked the quit gate (rungsTried empty); an earlier stop had succeeded only because it was gone by then. It does NOT clear by itself: nine swx_stop attempts one minute apart (16:55:13–17:03:15 UTC) all returned stop_blocked_by_dialog on the same window, which had appeared at ~16:52:08 — more than eleven minutes on an idle, empty session. So waiting and re-censusing is not a remedy, and 0.9.4 adds none: the modality judgement is the fix.

0.9.4 judges windows by measured modality (quit::judge_window): a window blocks when its kind is a known blocking kind, its owner frame is DISABLED, or it has a Button-class control; it is a notification — reported with its evidence (owner, ownerEnabled, styles, rect, child controls), never answered, and NOT a reason to refuse a quit, a sweep or an import — only when it was measured to be a tool window with an enabled owner and no Button-class control. Anything unread fails closed: blocking. A window whose readable captions are all template placeholders says textIsTemplatePlaceholder:true and does not present them as its words. neverAnswer is unchanged.

Live proof 5 — 0.9.4 on Drew's machine (2026-10-01)

Forced with refresh_bridges (0.9.3 → 0.9.4).

Measured
Notification rule with the 3DEXPERIENCE "Connected" notice on screen, swx_stop quit in 5.1 s and reported it as a notification (blocking false, the reason, textIsTemplatePlaceholder, the evidence)
Launch gate 16 PASS — READY at 19.3 s; three swx_start after READY launched nothing. It still EXITED 3: its last, optional step (the UIA reply capture) failed every run — see below
Ownership sweep PASS both halves (Part1 25,551 bytes verified and closed; Drew's part closed in place, bytesNow 32,311)
Drain gate 5 / 5
Quit gate PASSED, exit 0 — quit 4.4 s, relaunch 17.2 s
Priming proven live: declined at 130 s on a never-had-a-document session, primed, quit in 3.4 s
Age of a decline never-had-a-document sessions quit unprimed at ~25 s (twice) and declined at 50 s, 130 s, 150 s, 8 min; platform load 24 s — QUIT-INVESTIGATION §5.17

THE "TRANSIENT" WAS NEVER TRANSIENT — corrected. The fast, empty shell_execute failure (exit 1, empty stdout and stderr, ~260–300 ms) recorded on 09-30 and 10-01 as a transient was, measured: on 09-30, curl.exe against the dead loopback port (curl exit 7 surfacing as an empty exit 1); on 10-01, the launch gate's UIA probe, every time (four attempts in a row, and on both earlier runs) — its script curls $env:ADOM_DIRECT_API_URL, which is not set in the shell shell_execute gives it. 0.9.5: every gate script runs inside _wrap, which prints PS-ERROR: lines for a terminating error and, on a non-zero native exit, PS-NATIVE-EXIT: plus every error PowerShell recorded, so empty stderr cannot hide a failure again (the bounded retry stays only for a script that never ran); the UIA capture says why it has nothing (no direct-API address in that shell, curl's exit and output, or the exception) and, being optional and read-only, can no longer abort a gate whose claims passed — a failure is a NOTE.

Unattended sign-in — 0.9.13 (2026-10-02)

Drew: "can we please figure out how to sign me in? … you can't launch the thing without me manually typing in my password every time. Can you type in that window at all?"

Measured: the sign-in is a native window, class DSYSysLoginPanelWebView2Class, title "Login | 3DEXPERIENCE ID | Dassault Systèmes", owned by SWXDesktopLauncher.exe — WebView2 content, reachable through UI Automation. It appears when the 3DEXPERIENCE platform session has EXPIRED: on none of ~15 launches on 09-30/10-01, then on 10-02 after overnight licence failures. So "every time" is "every time the platform session lapses".

What 0.9.13 does.

Inventory first Every time a launch reaches the sign-in, the window's accessible inventory — control type, name, automation id, IsPassword, toggle state, enabled; NEVER a value — is read through ab's desktop_ui_tree and kept in the launch state (swx_status.signInInventory). Nobody has seen that tree yet; the next expiry measures it even if the user signs in by hand
swx_sign_in {reason} Only for a launch this bridge started, its own chain showing the window. Recognizes the form from the inventory alone (exactly one IsPassword edit, at most one other edit, one button named like a sign-in); takes the login from the user's pup vault LOCALLY through ab (pup_credential_reveal); types it with desktop_ui_set (sensitive:true); ticks a "Remember me"-type checkbox if present; presses submit; zeroes its copy; verifies by EFFECT — the window closes
Fails closed sign_in_credential_missing (+ the import command pup_import_browser_logins {"hostFilter":"3ds.com"}), sign_in_window_unrecognized (+ inventory), sign_in_needs_user (a second step; the user is toasted), sign_in_rejected — NEVER retried, persisted (sign-in-rejected.json) until the user says the saved password changed, sign_in_unconfirmed, sign_in_vault_unavailable, sign_in_consent_denied, sign_in_fill_failed
The secret Never in a reply, a log line, the ledger, an error text, a fixture or a command line: every value leaving the module is scrubbed of it (and of credential-bearing keys); its copy is zeroed after the fill. Best effort: the HTTP buffers that carried it are dropped, not wiped
signIn:"auto" swx_start {signIn:"auto"} signs in once by itself when the window appears; the default stays wait
Ledger solidworks_sign_in: who asked, why, the outcome, the consent ab reported — no secret
Status after a respawn waitingOn/signInWindow come from a LIVE census of the recorded chain whenever the record names the sign-in stage (measured 10-02: a respawn mid-launch left them null)
Reconciled on attach A record still saying helperSignIn/abandoned once COM is attached moves to stage attached; outcome and attribution keep their evidence, and the re-attribution refusal is kept as supersededByAttach (measured 10-02)

Unmeasured, and the first live questions: the real reply shapes of desktop_ui_tree, desktop_ui_set and pup_credential_reveal (the parser is lenient, the addressing passes automationId + contains + role); what consent pup_credential_reveal needs (vault.consent reports what ab says, unmeasured otherwise); whether ab itself logs a desktop_ui_set value marked sensitive; which vault host the 3DEXPERIENCE login is saved under (SWX_SIGN_IN_HOST, default 3ds.com); and whether the form is one page or two (on a username-only first page the user field is filled and the button pressed; the page that follows is reported sign_in_unconfirmed with its inventory, and a second swx_sign_in fills the password page — the outcome is verified each time, never assumed).

MEASURED 2026-10-02 on ab 2.1.163 (harmless calls), and 0.9.14.

Measured 0.9.14
desktop_ui_set without a value: "desktop_ui_set requires text (the value to set)." Documented shape {window|hwnd, name|contains, text} (submit:true sets and commits); automationId appears in examples only as a reported field 0.9.13 sent value — every fill would have failed. Now text, targeted by name + contains (automationId still sent, never relied on). sensitive:true is not documented; it is still sent (unknown arguments are ignored). Whether ab logs the text is UNMEASURED
desktop_ui_tree {hwnd, maxDepth, maxNodes} top level {foreground, hwnd, maxNodes, nodeCount, root, truncated, window, status, success}; nodes {role, name, automationId, invokable, settable, rect, children} (per its docs also value / toggleState / expandState / selected); NO controlType, no isPassword on the nodes sampled Read by role (and controlType, so both shapes work); invokable / settable honoured; the password edit is the one with a password flag OR a name / automation id matching /pass(word)?|mot de passe|contrase/i; a truncated tree is unrecognized, never a guess; a node's value is never copied. The measured node shape is a test fixture
pup_credential_reveal {} "needs {host, reason} (reason 8+ chars; it is shown to the user)"; hint: "Reveal ONE saved login (username + password) to a LOCAL caller on the desktop, gated by a sticky consent toast the user must click. One host per call, never bulk. A relayed (cloud container) call is refused with relay_forbidden" Consent is PER USE (vault.consent:"perUse", from ab's own text). The toast reads "Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login". consent_pending is re-called until answered or SWX_SIGN_IN_CONSENT_SECS (120 s) → sign_in_consent_pending; a refusal → sign_in_consent_denied; relay_forbidden → sign_in_relay_forbidden, reported as a bridge wiring bug (the bridge must reach ab as a local caller), not a user problem
desktop_ui_click {hwnd} with no name "control found but has NO InvokePattern…" — it needs name/contains Every click targets by name + contains
The vault no 3DEXPERIENCE entry yet (import consent pending on Drew's side) EXISTENCE FIRST: pup_credentials {} (host + username, no toast) is read before any reveal; no login on the 3ds.com domain → sign_in_credential_missing WITHOUT raising the consent toast. The reveal asks the vault host found there (preferring iam.*)

THE FIRST REAL LOGIN WINDOW, MEASURED 2026-10-02, and 0.9.15. Raised by SOLIDWORKS Visualize 2026 (pid 56488): class WindowsForms10.Window.8.app…, title "Login - 3DEXPERIENCE ID - Dassault Systemes", 968x968 — the same web page SOLIDWORKS's launcher hosts in DSYSysLoginPanelWebView2Class ("Login | 3DEXPERIENCE ID | Dassault Systèmes"), which is still unmeasured.

Measured 0.9.15
T1 — the tree is lazy the first desktop_ui_tree {maxDepth:30} gave 13 nodes, one control (Button "Close"); after one desktop_ui_from_point query on the page, 38 nodes with the form A read with no form wakes the page — desktop_ui_from_point at the window's centre, a short pause, read again, up to three times — and records every node count (wake.nodeCounts, signInWake)
T2 — two pages page 1: Edit "Email or username" (automationId username, settable), Button "Continue", hyperlinks, a cookie banner, Button "Close"; NO password box. Page 2 unmeasured ONE flow: page 1 recognized (one box that says it is the user name + a Continue-like button) → user name → Continue → wait for the page to CHANGE (woken) → page 2 recognized (a password box + a submit), its inventory captured → password → submit → verified by effect. The vault is asked ONCE; the secret is held only across the two fills, then wiped. A password page that never comes types no password (sign_in_unconfirmed)
T3 — cookie banner "Accept All", "Manage my preferences", Text "Continue with only necessary cookies"; it does not cover the form Never clicked (the choice is the user's), never a submit candidate; reported cookieBanner:true; a set or click that fails with the banner up is sign_in_needs_user, and the user is told
T4 — both hosts — The title rule already matches the Visualize host ("3DEXPERIENCE ID" + "Login"); the measured cold (13) and warm (38) page-1 reads are test fixtures. The bridge does not launch Visualize, so a Visualize login is not one swx_sign_in will answer
K1 — no background set desktop_ui_set {name:"Email or username", text} on the settable:true Edit: "control found but has NO ValuePattern (can't set text via UIA — try a foregrounded desktop_type)" The UIA set is tried first; on that refusal the bridge types REAL keystrokes: desktop_screenshot_window → desktop_click {space:"image", shotId, x, y} on the box → desktop_press_key ["ctrl+a"] → desktop_type, every call with a reason
K2 — collision keystrokes sent while Drew typed in Chrome (last input 0 s) left "ogin" — HIS keystrokes — in the box, and the text did not land No keystroke unless the user has been idle (SWX_SIGN_IN_IDLE_SECS, 5 s; unreadable = not idle) → sign_in_needs_user_idle + a toast asking for five seconds hands-off; the login window confirmed as the FOREGROUND window before every batch (aborts if focus moved); the user-name box READ BACK and compared (the password box never is); a mismatch stops with what was observed. Enter is never pressed
K3 — dirty field "ogin" stayed in the box after the failed attempt Select-all before typing
K4 — background submit desktop_ui_click {name:"Continue"} worked without focus Submits stay background Invokes
K5 — the vault pup_import_browser_logins {hostFilter:"3ds.com"} answered no_data — Drew's browser holds no saved 3DEXPERIENCE login sign_in_credential_missing names all three ways to grow the vault: the import, signing in once in a pup window (pup captures it), or credential_set by the user on their desktop

Unmeasured, stated: whether keystrokes posted to the embedded browser's own child window work without focus (would avoid stealing focus); the image-space mapping (x = element centre − window origin on a window screenshot); page 2.

T5 — SOLIDWORKS Visualize 2026, measured. Installed at …\SOLIDWORKS 3DEXPERIENCE R2026x\Visualize\SLDWORKSVisualize.exe; it takes a model path as its argument; it ships no scripting interface on disk (UI automation only); and it demands its OWN platform sign-in even while SOLIDWORKS is signed in. No rendering verbs in this build: Drew wants rendering intelligence only once a render has worked by hand, and that waits on his sign-in.

The dependent skill. skills/solidworks-sign-in/SKILL.md ships in the package as a second user skill: the measured flow, each line marked measured or unmeasured, and NO credentials — only where they come from in general terms.

MEASURED 2026-10-02 on the Visualize login window, and 0.9.16.

Measured 0.9.16
P1 — page 2 Edit "Email or username" (prefilled), Edit "Password " (automationId password), CheckBox "Remember me " (toggleState off, rect OFF-SCREEN: centerX -14493 — the native input is hidden; the visible control is the Text "Remember me…"), Button "Log in", Button "Back", the cookie banner still present Page 2 is now a measured fixture. "Remember me": already on → nothing; an on-screen invokable box → a background Invoke; an off-screen one → a real click on its visible LABEL (only while the user is idle, with the window raised and in front), then the toggle state read again; a tick that cannot be made safely is skipped and said, never failing the sign-in
P2 — geometry moves moving the window re-laid the page; the cookie banner then COVERED the email box; a click at the earlier point hit the banner, select-all selected page text, the typing went nowhere Before every real click the tree is RE-READ and the click lands at the field's own rect.centerX/centerY in screen px (desktop_click {x, y}). A field a cookie-banner element overlaps → the window is made 1100x1450 (desktop_set_window_bounds {hwnd, x:300, y:30, w:1100, h:1450} — MEASURED 2026-10-02: replied ok, email box at y=787 above the banner at y=1020) and re-read; still covered → sign_in_needs_user (the banner is the user's)
P3 — focus a plain click does not take focus while the user's app is active (the login thread's focus was 0); desktop_raise_window {hwnd, reason} brings it forward and then the click lands; desktop_type {text, hwnd:<Chrome_RenderWidgetHostHWND child>} types; reading the user name back works Raise, confirm the login window is the foreground window, click, then select-all and type to the render widget when the tree names its handle (else the window)
P4 — the vault now holds the login (host iam.3ds.com, stored with credential_set) The reveal asks the host the vault lists on the 3ds.com domain (iam.* first)
P5 — policy typing the password through ab FROM THE CLOUD is refused by the auto-mode classifier ("Credential Leakage") — rightly The only path is the bridge reading the vault LOCALLY and typing locally. So swx_sign_in {userAuthorized:true} (the user's say-so) answers ANY 3DEXPERIENCE ID login window on the machine — our launch chain's, SOLIDWORKS Visualize's (SLDWORKSVisualize.exe is now in the census), any Dassault process's — and reports which process owns it; window:"visualize"|"solidworks"|<hwnd> picks one when several are up (sign_in_window_ambiguous otherwise). Without the authorization, only the bridge's own launch's window. This wires the unwired half of T4

MEASURED 2026-10-12 on a third login host, and 0.9.23. After a week the platform session had lapsed. swx_start {app:"visualize"} answered stage:"launching" after 91 s, and swx_status then said visualize.running: false, waitingOn: null. The bridge saw nothing, while a sign-in sat on screen.

Measured 0.9.23
U1 — the host a window titled exactly "3DEXPERIENCE", class WindowsForms10.Window.8.app.0.30495d1_r6_ad1, UIA automationId SWXDesktopUpdateForm, owned by SWXDesktopUpdate.exe from C:\Users\drew\AppData\Local\Temp\SWXDesktopUpdate\ (started the second the shortcut was invoked). Content: "Welcome to the 3DEXPERIENCE platform / Sign in to continue / Email or username / Continue", a "Manage cookies" link, no cookie banner SWXDesktopUpdate.exe is in the census (by image name, so any path counts; never terminable). A login is recognised BY CONTENT: a visible, titled window (not SOLIDWORKS's own, not Visualize's main window) in which desktop_find_control finds an Edit "Email or username" (page 1) or "Password" (page 2), whatever its title, class or owner. The class/title rule stays as the fast path. Probes are cached per window for 3 s (SWX_SIGN_IN_PROBE_TTL_MS)
U2 — the refusal swx_sign_in {userAuthorized:true, window:857684} → sign_in_not_waiting "no 3DEXPERIENCE ID login window matching the request is open" A window handle the caller names (with userAuthorized) is never refused on census grounds: it is probed by content, and refused only as sign_in_window_not_a_login with found (every control looked for, and what was found). window:"desktopUpdate" picks the updater's login
U3 — the tree is blind desktop_ui_tree {hwnd} = 14 nodes (Window, MenuBar, Minimize/Maximize/Close) even at maxNodes 600 / depth 40 and after desktop_ui_from_point; desktop_find_control reaches the page: {role:"Edit", name:"Email or username"} → automationId username, settable; {name:"Continue"} → Button, invokable; {role:"Edit"} alone → "Address and search bar" (view_1012), a wrong match from elsewhere When the tree holds no text field, each control is located with desktop_find_control: automation id first, then role + exact name, then role + name-contains, never a role alone; a match counts only if its role and its id or (cleaned) name say so. pages[].located records the source and every control looked for. The window is remembered as tree-blind so later reads skip the useless wakes
U4 — page 2 Edit username (prefilled); Edit automationId password, NAME "Password " + a private-use icon glyph (an exact-name query does NOT match; {automationId:"password"} does); Text "Password"; Text "Remember me" (no CheckBox by name); Button "Log in"; Button "Back" Fields are targeted by automation id. "Remember me": its label is clicked (idle user, window raised and in front); its state is unreadable, so rememberTicked: null and the step says so
U5 — the set desktop_ui_set {hwnd, automationId:"username", text} WORKS here (ValuePattern accepted), but the first set left the user name plus a stray trailing "s"; setting "" first, then the value, fixed it. find_control's reply carries NO value Every field found this way is CLEARED then set ("", then the value). The user name is read back when anything can read it (the tree, or a value in a find reply); on this host nothing can, so the step says readBack: "unreadable" rather than claiming a match. The password is never read back. On "NO ValuePattern" the keystroke path runs as before, with its geometry from find_control
U6 — parked Visualize never started while the updater's login was up swx_status reports waitingOn:"userSignIn" + signInWindow {hwnd, title, ownerImage, host, recognizedBy} whenever ANY login window is up (signInWindow is now an object; it was a title). visualize.waitingOn:"userSignIn" + parkedBehind while Visualize is not running and the updater's login is up; swx_start {app:"visualize"} returns stage:"waitingOn" at once instead of waiting out waitSeconds and saying "launching". A SOLIDWORKS launch treats the updater's login as its own sign-in stage (deadlines paused, nothing killed); the updater counts as the bridge's own launch when it started at or after the bridge's invoke

| U7 — ab's policy (0.9.25) | with the 0.9.24 envelope fix, pup_credentials answered (the vault holds iam.3ds.com) and pup_credential_reveal was refused: "pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin "bridge"". ab POLICY: the secret goes only to a local desktop assistant. Filed adom/adom-bridge #237 (desktop_credential_fill, or a verified-local origin for hosted bridges) | The page is PREPARED BEFORE THE REVEAL: the user name (from pup_credentials' host list, no secret, no toast) is filled and Continue pressed, with the same idle/foreground rules; the password page is recognised; only then is the reveal asked, once. A verified/local/origin refusal is sign_in_vault_policy (not sign_in_vault_unavailable) with abSaid (ab's exact message), pagePrepared {page, userNameFilled}, waysOut (the user types the password; #237) and a toast telling the user. Never retried, never worked around. swx_status / Visualize how and parkedBehind texts say the same |

| U8 — page 2, awake (0.9.26) | hosted 0.9.25 on window 857684, on page 2 with the name filled: the tree was AWAKE (37 nodes, located.source:"tree", 0 wakes; the earlier find_control probing had woken it; 14 nodes is the COLD state). Page 2 holds Edit username (prefilled), Edit password, a real on-screen CheckBox "Remember me " (settable, toggle pattern), Log in, Back, links incl. "Manage cookies", RootWebArea "Login | 3DEXPERIENCE ID | Dassault Systèmes"; no banner. 0.9.25 called it page 1, tried to set the name ("UIA SetValue failed: Cannot call SetValue on read only value"), blamed a cookie banner (from the "Manage cookies" LINK), toasted the user to answer it, and never asked the reveal | A page with the password edit is page 2 (or a one-page form), whatever else it holds. On it, a name that holds text is left alone, and a read-only refusal means nameField:"prefilled" (userNameFilled:true); no name failure blocks the password step. cookieBanner = an accept/reject/preferences BUTTON only; a failed set is never banner evidence (the toast goes out only with a real banner and a covered field or failed click). "Remember me" on screen: Toggle pattern (desktop_ui_toggle), then Invoke, then the label; rememberTicked comes from the read-back state |

Unmeasured, stated: what this host shows after "Log in" (a wrong password, a second step: rejection markers cannot be read from a blind tree, so an unclosed window is sign_in_unconfirmed, never retried); whether "Remember me" can be read here; whether any ab verb reads a field's value on this host.

MEASURED 2026-10-12 19:00Z (hosted 0.9.27), and the fix after it. With SOLIDWORKS not running (processCount 0) and the updater's login up, swx_status.launch still said stage "attached" and advice "SOLIDWORKS is up and COM-attached. Carry on." about the launch of 2026-10-02 (ageSeconds 878026, attribution pid 32224). The record had outlived its process by ten days and several bridge restarts, and the advice contradicted solidworks.running:false in the same reply. The top-level waitingOn / signInWindow were right: they come from a live census, never from the record.

Fix
A record that outlived its processes When COM is not attached and no launch is in flight, a finished or "attached" record is checked against the census by IDENTITY (pid + creation time, so a reused pid does not count): the attributed process, the SOLIDWORKS it attached to, and its chain. If none of them is running, launch reports stage:"ended", finished:true, endedWhy (the pids it named), recordedStage (what the record said), and advice that the launch is over and swx_start begins a new one. It never says "attached" or "carry on" then. The record on disk is not rewritten; the report is