SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
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 Beginis the signal. It is logged by a SECOND thread ~2 seconds afterSecurityContext:, 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.exewas up;3DEXPERIENCELauncher.exeran 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:
helperSignInis not a stall. The clocks pause from the helper's log untilsldworks.exeappears (see the table above).- No re-invoke while this launch has a SOLIDWORKS. Any
sldworks.exeoutside 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 forSWX_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 untilSWX_LAUNCH_DIALOG_GRACE_MSafter 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 byswx_start. It is ledgered asdialog_cancelledwithby:"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:
- the image is on
TERMINABLE_IMAGES=CATSTART.exe,SWXDesktopLauncher.exe— and nothing else, ever; - the image is not on
NEVER_TERMINATE(SLDWORKS.exe,swCefSubProc.exe,explorer.exe) orNEVER_TERMINATE_PREFIXES(3DEXPERIENCELauncher, matched as a prefix so a renamed or versioned daemon cannot slip through); - the creation time is KNOWN — an unreadable creation time is "not ours", never "probably ours";
- 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 byCtx::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_chainre-derived its list from the census — ignoring theAttempt.chainit 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 aspreferredRouteeven when the attach came from the user launching SOLIDWORKS mid-flight. It now requiresRun Beginin 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_healthtook the firstsldworks.exein 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, andreportedProcessBasissays which rule was used.owned-docs.jsonwas a plainfs::writeon 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.jsongot the same treatment, andswx_statusno longer rewrites it on every poll.prune_scratchcompared paths byte-exact whileowns_docusedsame_path, so a document SOLIDWORKS reported under a different casing was pruned out of the registry that would have granted it. Both now callverbs::doc_matches.component_count's.unwrap_or(0)was the success test forswx_add_component: a failed enumeration made a successful insert look failed and the method ladder inserted again. It is now anOption, and an unreadable count stops the ladder withcomponent_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_IMAGEScontained no shells — so the root's parent link always dangled anddescendswas false; - on the
explorerroute, our CATSTART and a Start-Menu CATSTART have literally the same parent: the user'sexplorer.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_candidatesis 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_chainre-confirms every recorded member withProcInfo::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.exejoined the census as parent links only, and the never-terminate list besidesldworks.exeand3DEXPERIENCELauncher*.- 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", andis_newer_thananswered(Some(_), None) => true— any log is ours. Five of the nine logs on the real machine containRun Begin, so the bridge would have read a launch from three days ago, jumped straight torunBegin, 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_thanshort-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 nowstamp_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_outwas decided bye.message.contains("did not answer")in two files, a copy of a sentence built in a third.SidecarErrorandHostErrornow carry an explicittimed_outflag over the wire, and an unrecognised com failure yieldshung: null— a crashed com host says nothing about whether SOLIDWORKS is wedged.swx_interference's hint now says whatcount: nullmeans, in the terms that matter: null is falsy in every language this bridge's callers use, soif (!r.count)turns "I do not know" into "all clear" on a clearance report somebody builds a physical part from. Branch oncountKnown.- 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_atomicretries 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.jsonnow falls back to the.bakgeneration, 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": falseper route) — the shapeShellExecuteExWon a.lnkis most likely to produce, since the shell may hand the link to an already-running process by DDE and return nothing. And"invisibleChain": truemodels a chain the census never shows, which is hownotConfirmedis 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 routeshellExecutecome back with arootPidon 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 (orunattributed), 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):
- AUTHORSHIP. The launcher stamps the writing thread's id on every line
(
17:23:09 [116244] info Run Begin). Ifthread_owner(tid)resolves to a process in OUR OWN chain, the log is ours — positively. A miss proves nothing, because threads exit. - 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). - 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
lastRouteBeforeAttachrecords which route was in flight when COM attached, with"proof": false— an ordering hint for a human. It never satisfiesrun_begin_seenand is never promoted topreferredRoute. Added only after H1 was fixed, so it cannot be persisted from a corrupted signal.finish()is idempotent — a second call was bumpingconsecutive_failuresagain, doubling the backoff for one attempt.ledger.jsonl,timings.json,settings.jsonand the com host'scom-host-created.jsonnow go throughwrite_atomictoo. 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.bakcopy is staged through a unique name and renamed into place, so two writers cannot leave a torn backup — which matters because the backup is whatowned::restorefalls 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 Beginat 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.
- Whether any of
shellExecute/powershell/exploreractually reachesRun 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. - The route ordering. It is now decided by ATTRIBUTABILITY, not by which
mechanism is most likely to work —
shellExecuteandpowershelllead because they can say what they started. If the gate shows the launcher only comes up onexplorerorcmdStart, 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. - That killing a stalled
CATSTART/SWXDesktopLauncheris clean — that it leaves no dialog, no half-registered session and no Platform complaint. - Creation-time resolution.
GetProcessTimesis 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 emptychainPidsand a cleanup with nothing to do — visible, and safe in the direction that matters. Theppidwalk and the baseline diff are what actually carry the attribution. - ab's
desktop_find_controlreply shape has never been seen, and the whole click decision is read from it. Crash recovery therefore ships disabled;tools/launch-gate.py --probe-uiacaptures 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" (acontains) because Drew reported the label as "Restart SOLIDWORKS Design";SWX_CRASH_REPORTER_TITLEwidens the window matcher without a rebuild. IsHungAppWindowagainst a genuinely wedged SOLIDWORKS, and thePrivateUsagefigure against Task Manager's.- 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:
- 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.
tests/wiring.rslists 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 |
# 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:
```json
{"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:
```json
"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:
```rust
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
```bash
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 |