SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
477e64f
1mo ago
Fusion-parity brief — solidworks-bridge v0.3 (swx)
Drew's ask (2026-09-02): "Build the functionality to open an existing file or create a new one. Learn EVERY capability of the Fusion bridge (wiki.adom.inc/adom/fusion-bridge), build a SW alternative for all of them, and tell me what is lacking when done."
Quality bar is unchanged: kicad/pup/native-browser. Every response carries a _hint;
stable errorCodes; background etiquette; progress on slow verbs; a suite that cannot lie.
The v0.2 codebase (Rust, single exe, killable COM child) is the base — extend, don't rewrite.
What is PROVEN LIVE on Drew's machine (2026-09-02) — build on these facts
- SOLIDWORKS 3DEXPERIENCE R2026x ("SOLIDWORKS Design Professional for Makers 2026 SP3.0",
sldworks.exe v34.3.0.0150) at
C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\sldworks.exe. - Out-of-process COM attach WORKS (GetActiveObject →
swx_com_eval $sw.RevisionNumber()→ "34.3.0"). PowerShell's late-binding adapter FAILS the same call (TYPE_E_ELEMENTNOTFOUND); our raw GetIDsOfNames+Invoke path works. Never reintroduce a PowerShell layer. - Creating documents:
$sw.NewPart()HANGS (>110s, killed by the watchdog; no dialog left). The working recipe isNewDocument(template, 0, 0, 0)with the stock templates:$sw.GetDocumentTemplate(1,"",0,0,0)→C:\ProgramData\SolidWorks\SOLIDWORKS 2026\templates\Part.prtdot$sw.GetUserPreferenceStringValue(9)→...\templates\Assembly.asmdot(swDefaultAssemblyTemplate=9; swDefaultPartTemplate=8 returned "" — so resolve part templates via GetDocumentTemplate, and drawing templates via GetDocumentTemplate(3,...)). Live: NewDocument(Part.prtdot) → "Part1"; swx_docs sees it; swx_mass_properties runs (mass 0 on an empty part); swx_close_doc {save:false} discards it; swx_docs is empty again. - No local .sldprt/.sldasm exist on the box (Connected keeps documents in the 3DEXPERIENCE cloud), so every document-verb test must CREATE its own scratch document, exercise it, and close it WITHOUT saving. Files saved to disk for tests go under the bridge scratch dir and are removed.
- 13/13 no-document verbs pass live; crash recovery (kill+respawn of the COM child) proven live.
- Two detection bugs were found live and are fixed (partial-component glob; case-insensitive tasklist match). Lesson: mock-only green is not green — validate every new verb LIVE.
Dev loop that works (use it; do not invent another)
cargo build --release --target x86_64-pc-windows-gnu # exe ~850K
# the container already serves target/x86_64-pc-windows-gnu/release/ on :8877 via python http.server
# -> https://drew2-gallia-7g17dw3vho0f.adom.cloud/proxy/8877/swx-bridge.exe (anonymous OK)
# on the desktop (ALWAYS --target adom_drew2; 2 bridge clients are on the relay):
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" shell_execute '{"command":"powershell -NoProfile -Command \"Invoke-WebRequest -Uri https://drew2-gallia-7g17dw3vho0f.adom.cloud/proxy/8877/swx-bridge.exe -OutFile \\\"$env:LOCALAPPDATA\\Adom Bridge\\bridges-cache\\solidworks-dev\\swx-bridge.exe\\\" -UseBasicParsing\"","timeoutSeconds":180,"reason":"..."}'
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" process_kill '{"pid":<old>,"reason":"..."}' # old dev bridge (pid was 95412)
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" launch_process '{"path":"%LOCALAPPDATA%\\Adom Bridge\\bridges-cache\\solidworks-dev\\swx-bridge.exe","args":["--port","24695"],"window":"hidden","env":{"ADOM_BIND_HOST":"127.0.0.1"},"reason":"..."}'
python3 tools/swx-live.py <verb> '<json>' [timeoutSec] # calls POST /command on the desktop via curl.exe
Every gated ab verb (shell_execute, launch_process, process_kill, write_file) needs a reason
written for the human. tools/swx-live.py already does this.
HARD RULES while driving Drew's live SOLIDWORKS
- NEVER call
swx_stop/ ExitApp / process_kill on sldworks.exe. Drew is using it. - NEVER close or save a document you did not create. Scratch docs you create are closed with save:false. Never leave a scratch doc open at the end of a test.
- NEVER foreground SOLIDWORKS; never send keys/clicks to it. COM only.
- A verb that can hang gets a bounded timeout; the COM child watchdog is the safety net, not
the plan. Test any NEW COM call first via
swx_com_evalwith a short timeout. - Keep the 23 existing verbs' shapes backward compatible. Bump version to 0.3.0 (Cargo.toml + BRIDGE_VERSION + bridge.json + package.json in lockstep — lib.rs asserts it).
The Fusion bridge's capability surface (inventory) → the SW plan
Source: wiki page adom/fusion-bridge v1.9.341 (bridge.json 57 manifest verbs; describe.py catalog ~110 verbs; README_VERBS.md categories). Cloned at /tmp/claude-1001/-home-adom-project/ca530728-41cc-4f93-a92d-d32d4afe1153/scratchpad/fusion-bridge/ (read VERBS.md, README_VERBS.md, describe.py, SKILL.md, README.md, docs/.md for exact arg/response shapes — mirror arg NAMES where the concept is the same so an AI that knows fusion_ can drive swx_* with no relearning).
A. Lifecycle & app state — BUILD (full parity)
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_start / fusion_launch | swx_start (exists) | add visible, stillLaunching polling semantics + measured cold/warm history (SDK progress rule) |
| fusion_stop | swx_stop (exists) | keep; refuse when unsavedDocs>0 unless force:true |
| fusion_kill | swx_kill | taskkill /F sldworks.exe — gated, requires reason + confirm:true |
| fusion_readiness | swx_readiness (exists) | add addinStale:false-style parity fields: comAttachable, version, hostPid |
| fusion_get_app_state | swx_get_app_state | = today's swx_status+state merged into Fusion's shape: running, activeDocument{name,type,path,dirty}, documents[], workspace(part/assembly/drawing), busy, blockingDialogs[] |
| fusion_state | swx_state (exists) | ADD the screenshot: base64 PNG of every SOLIDWORKS window via ab desktop_screenshot_base64 {hwnd} (callback), plus dialog census |
| fusion_document_info | swx_document_info | active doc metadata: name, type, path, isSaved/dirty, configurations, units, custom-prop count |
| fusion_activate_document | swx_activate_document | $sw.ActivateDoc3(name, useUserPref, option, errors) — by name or path |
| fusion_close_document | swx_close_doc (exists) | add name arg parity; save:false default silent |
| fusion_close_all_documents | swx_close_all_documents | $sw.CloseAllDocuments(includeUnsaved); DESTRUCTIVE → require confirm:true; skips dirty unless closeUnsaved:true |
| fusion_reset_workspace | swx_reset_workspace | close all scratch docs the bridge created (track them in a ledger) |
| fusion_dismiss_blocking_dialogs / fusion_close_window / fusion_dismiss_recovery | swx_dismiss_blocking_dialogs, swx_close_window | enumerate SOLIDWORKS-owned dialog windows (via ab desktop_list_windows callback, owner pid = sldworks), classify by title (Save changes?, Recovery, license, update), WM_CLOSE by hwnd via ab desktop verbs; never auto-answer "Save changes?" |
| fusion_window_info / screenshot_fusion / screenshot_all / take_screenshot | swx_window_info, swx_screenshot (exists) | screenshot_all = every SW-owned window; use ab generic verbs only |
| fusion_action_ledger | swx_action_ledger | append-only ledger of background actions (docs created/closed, dialogs dismissed, child respawns) in the scratch dir |
| fusion_verb_times | swx_verb_times | measured per-verb timings store (p50/p90, cold vs warm) — feeds progress blocks |
| fusion_live_feed | swx_live_feed | LATER (v0.4) — note as lacking |
| fusion_install_fusion / signin / signin_2fa / set_auto_update / set_seat_takeover / set_window_sizing / set_driving_badge | — | NOT APPLICABLE / DEFER: SW has no silent free installer (reseller/Makers store), sign-in is the 3DEXPERIENCE launcher's. Driving badge (taskbar overlay via ab) is CHEAP → build swx_set_driving_badge if time allows; report the rest as lacking with reasons |
B. Files: open / create / save / cloud — BUILD (Drew's explicit ask)
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_open_design (local) | swx_open (exists) | already silent OpenDoc6; add activate:false option, readOnly, return progress (measured) |
| NEW (no Fusion twin, Drew asked) | swx_new_document | `{type:"part" |
| fusion_import_step | swx_import | open STEP/IGES/Parasolid/STL as a new part via $sw.LoadFile4(path, "r", null, errors) (import) — live-test; fall back to OpenDoc6 for native |
| fusion_save_to_cloud | swx_save_as | $doc.Extension.SaveAs3(path, 0, 1, null, null, errs, warns) local save-as (Connected also supports "Save to 3DEXPERIENCE" — expose swx_save_to_cloud only if provable live via API; else report lacking) |
| fusion_export_{step,iges,stl,3mf,obj,dxf,dwg,sat,f3d,usdz,fbx,skp} | swx_export (exists, one verb) | add per-format aliases swx_export_step/iges/stl/3mf/dxf/dwg/pdf/parasolid so the arg surface matches; verify each format LIVE on the scratch part (SaveAs3 keys off extension; 3MF/OBJ may need swExportOptions — test; report unsupported ones as lacking) |
| fusion_aps_search / browse / recent / open / file_info / versions / download / upload / create_folder / list_cloud_projects / list_cloud_files / walk / search_cloud_files | swx_cloud_* | RESEARCH + STUB: 3DEXPERIENCE has the 3DSpace/3DDrive REST APIs (tenant https://oi000616940-usw1-makers-ifwe.3dexperience.3ds.com, CAS/passport auth). This is the Fusion APS trail. Build swx_cloud_status (auth state) + swx_cloud_search + swx_cloud_open (OpenDoc7 / PLMObjectSpecification via COM) ONLY if you can prove ONE call live; otherwise ship a clear errorCode:"cloud_not_configured" scaffold + document exactly what auth/API is needed. Report as lacking. |
| fusion_cleanup_cloud_files / delete_cloud_file / export_cloud_file | — | lacking (cloud) |
| fusion_check_recovery | swx_check_recovery | detect the SOLIDWORKS Document Recovery pane/dialog |
| fusion_find (n/a) | swx_find_files (exists) | keep |
| fusion_pack_and_go (n/a) | swx_pack_and_go (exists) | live-test it |
C. Design intelligence & modeling — BUILD (the MCAD strength)
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_get_design_info | swx_design_info | feature count, body count, sketch count, units, configurations, material, referenced files (assemblies), rebuild state |
| fusion_get_parameters / set_parameter | swx_get_parameters / swx_set_parameter | global variables + dimensions: $doc.GetEquationMgr() (Equation, GetCount, Equation(i) get/put) and $doc.Parameter(name) / IDimension.SystemValue. Live-test on a scratch part with one extruded box (create via swx_run_script? see below) |
| fusion_inspect_bodies | swx_inspect_bodies | per body: bbox (mm), volume, area, faceCount, material, visible — $doc.GetBodies2(0,true) + IBody2.GetMassProperties / GetBodyBox |
| fusion_physical_properties | swx_mass_properties (exists) | add per-component breakdown for assemblies (parity with Fusion's per-component list) |
| fusion_assembly_bom | swx_bom (exists) | add indented:true (level column, parent path), kitAware no-op, and CSV column ordering parity |
| fusion_run_modeling_script / execute_text_command | swx_run_script | THE escape hatch with teeth: a small, safe, deterministic modeling DSL executed on the COM child — at minimum new_part, sketch_rect(w,h,plane), extrude(depth), set_material(name), set_prop(k,v), save_as(path) so tests can build a real solid (the mass-properties/BOM/export tests NEED a body). Implement via the standard API: SketchManager.InsertSketch / CreateCornerRectangle / FeatureManager.FeatureExtrusion3 (test the exact late-bound signatures live; document the ones that work). Also keep swx_com_eval. |
| fusion_mcp_* (Autodesk MCP proxy) | — | NOT APPLICABLE (no SOLIDWORKS MCP from Dassault); note the community MCP servers exist (research from 2026-09-01) — lacking by design |
| fusion_board_stackup / electronics | — | not applicable (MCAD) |
D. Preferences & UI — BUILD (cheap parity)
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_get_preferences / set_preference | swx_get_preferences / swx_set_preference | units (GetUserPreferenceIntegerValue swUnitsLinear), theme not applicable (report), background/appearance where API allows; keep the set small and PROVEN |
| fusion_prefs_open / prefs_close | — | lacking (UI-driven) |
| fusion_click_fusion / send_key | — | deliberately NOT built (COM-first; foreground etiquette). Report as lacking-by-design with the etiquette rationale |
E. Show / eight-surfaces analog — BUILD the MCAD set
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_show_project / show_2d_board / show_3d_board / show_schematic | swx_show_part, swx_show_assembly, swx_show_drawing | open+activate+zoom-to-fit ($doc.ViewZoomtofit2()) in the BACKGROUND, return via swx_state screenshot |
| fusion_show_symbol / footprint / 3d_package / library | — | not applicable (electronics) |
F. Manufacturing / electronics / libraries / demo — mostly NOT APPLICABLE
gerbers, CPL, design rules, layers, EAGLE/Electron commands, .lbr libraries, managed libraries, package generation, molecule/GLB pipeline, fusion_demo tour: report each as not applicable to MCAD or as "lacking, candidate for v0.4" (e.g. swx_export_drawing_pdf, swx_demo tour).
Testing (app-testing discipline, adapted) — REQUIRED
- Mock parity: every new verb gets a MockBackend implementation with the same response shape, e2e coverage in tests/e2e.rs, and the verb-coverage gate keeps passing (bridge.json ⇔ describe ⇔ calls made, three ways). No test reads source as strings.
- LIVE smoke suite:
tools/live-smoke.py(python, runs from the container via tools/swx-live.py) that: creates a scratch part via swx_new_document → builds a real solid via swx_run_script → exercises design_info, parameters get/set, inspect_bodies, mass_properties, props get/set, export (step, stl, iges, pdf-for-drawing where possible) with files under the scratch dir → verifies bytes on disk → creates a scratch assembly, inserts the part twice (if provable) → swx_bom → interference → closes everything without saving → asserts swx_docs is back to the pre-run set → prints a PASS/FAIL table. It must leave Drew's SOLIDWORKS exactly as it found it. The Verifier runs this suite; the Builder runs it before claiming done. - Error paths for every new verb (missing args, wrong doc type, unsafe paths).
Deliverables
- Code + tests green (
cargo test, cross-compile), version 0.3.0, committed. - Updated README.md / SKILL.md / docs (verb families, the NewDocument recipe, cloud status).
- docs/FUSION-PARITY-REPORT.md: for EVERY Fusion capability (all ~110 catalog verbs): SW verb built (live-proven ✓ / mock-only ⚠) | adapted | NOT APPLICABLE (why) | LACKING (why + what it would take). Drew reads this; be honest and specific.
- Append "## Build notes — " to this brief for every plan deviation.
Build notes — 2026-09-02 (v0.3 build)
Every deviation from the plan above, with the assumption that turned out to be wrong.
The full verdict-per-verb picture is in docs/FUSION-PARITY-REPORT.md; this is the
record of where the BRIEF was wrong, which is a different (and more useful) list.
The brief's biggest wrong assumption: that SOLIDWORKS can be started
Section A said swx_start (exists) and asked for polling semantics. It cannot exist on
this machine. CoCreateInstance(SldWorks.Application) returns CO_E_SERVER_EXEC_FAILURE
(0x80080005), and launching sldworks.exe directly spawns a process that puts up
"SOLIDWORKS Design must be launched from the 3DEXPERIENCE Platform or from the desktop
shortcut created from the Platform" and never becomes a drivable COM server. Both were
observed live. GetActiveObject attach is the only working path.
swx_start is now attach-first and, when nothing is attachable, returns
errorCode:"host_app_launch_blocked" with instructions written for the human. The
CoCreateInstance path survives for classic desktop SOLIDWORKS, behind
Detection::edition() / can_launch() (the install path carries 3DEXPERIENCE /
Dassault Systemes). swx_stop now refuses while documents are unsaved and its hint
warns that on Connected it cannot start the app again — stopping is close to a one-way
door here, which the brief did not anticipate.
Cost of learning this: one stray refused sldworks.exe (identified by the pid
launch_process returned, and removed) and one modal briefly on the user's screen. The
constraint is now encoded in code, SKILL.md, README.md and the report so it cannot be
rediscovered the same way.
Four v0.2 COM bugs the live probes found (none visible to 49 mock tests)
SaveAs3returnsswFileSaveError_e, not a bool.0is success. v0.2 read it as a bool, so every successful export reported failure. Probes wrote a 15,836-byte STEP and a 22,304-byte IGES that the bridge called refusals. Now decoded, bit flags and all.- Exports were renaming the user's open document.
swSaveAsOptions_Silent(1) alone is a real Save As. Observed live: a scratch part came back titledprobe.SLDPRTafter an export. Exports now useSilent|Copy(3), andswx_exportasserts the title survived (documentRenamed). InterferenceDetectionManageris onIAssemblyDoc, notModelDocExtension. Every interference check in v0.2 failed with "unavailable". Confirmed live on the assembly doc.Feature::GetErrorCode2is uncallable late-bound on this build (it wants a single byref BSTR; every arity we can express is rejected), so v0.2's feature walk marked EVERY feature unreadable — an empty assembly reported 16 errors.GetErrorCode()(no args) works and returned 0 live;Extension.GetWhatsWrongCount()is the cross-check.
Where the brief's proposed COM calls were wrong or incomplete
GetUserPreferenceStringValue(8)DOES work (the brief said it returned ""). All three template preferences (8/9/10) and all threeGetDocumentTemplate(1|2|3,...)calls resolve on this install.template_for()tries GetDocumentTemplate first and falls back.SelectByID(the 5-arg legacy form) is enough for plane selection and is proven live. The brief impliedExtension.SelectByID2with its null callout argument; the legacy form needs no null, so there is one less thing to marshal wrong. (nullwas added to theswx_com_evalgrammar anyway — it is needed forLoadFile4.)FeatureExtrusion3takes 23 arguments and consumes the sketch thatInsertSketchjust closed — no re-selection needed. Proven live end to end:NewDocument→SelectByID("Front Plane","PLANE",0,0,0)→InsertSketch(true)→CreateCornerRectangle→InsertSketch(true)→FeatureExtrusion3(...)→GetBodies2(0,true)returned 1 body, andswx_mass_propertiesread back exactly 3.0e-5 m³ for a 50×30×20 mm box.GetMaterialPropertyName2needs a byref BSTR for its second parameter; the two-plain-string form is rejected.SetMaterialPropertyName2(config, db, name)works as documented (mass went from 0.03 kg at density 1000 to 0.081 kg at 2700).ISldWorks::NewPart()hangs — confirmed again;NewDocumentis the only route.
Section B: export formats, measured rather than assumed
The brief asked for per-format aliases and said to verify each live. Done, by exporting a real solid and checking the bytes:
- Work from a part: STEP, STP, IGES, STL, X_T, X_B, SAT, 3MF, GLB, PLY, AMF, 3DXML, PNG, JPG, TIF, XAML, PDF.
- Refused for a part (
swFileSaveError_e256, nothing written): OBJ, DXF, DWG, U3D. DXF/DWG are drawing formats; aliases still ship for them because a.slddrwis the right target and could not be tested. - VRML (
.wrl) HANGS: it raises a modal option sheet, the COM call never returns, and the watchdog killed the host after 110s — leaving the dialog on screen.swx_exportnow refuses it before it reaches COM (errorCode:"format_blocked"). This is a capability the brief did not anticipate needing to remove.
GLB working natively is a genuine advantage over Fusion, which needs an optimisation service for its GLB. Ten aliases ship (step, iges, stl, 3mf, glb, sat, parasolid, pdf, dxf, dwg); OBJ deliberately does not, because it does not work here.
Section C: swx_run_script is a typed DSL, not a script host
The brief called for "a small, safe, deterministic modeling DSL", and that is right — but
worth stating why it is not a port of fusion_run_modeling_script: Fusion hosts Python
in-process, SOLIDWORKS' scripting is VBA (a macro file format driven through a UI), so
there is nothing to eval. script is a JSON array of typed steps, parsed and validated
on the SERVER before the first COM call — a script that cannot work never half-runs on a
live session. Millimetres in, metres at the COM boundary, one conversion point.
cut (FeatureCut4) ships but was never executed live and says so in its result
(proven:false).
Section C: the assembly verbs could not be live-proven — and why
IAssemblyDoc::AddComponent5 returns Nothing on this install for every combination
tried: all swAddComponentConfigOptions_e values, a saved and an unsaved assembly, an
open and a closed part, and the older AddComponent4. No error, no dialog. The most
likely explanation is that SOLIDWORKS Connected does not permit local-file component
references (documents belong in 3DSpace), but that is inference. The brief's plan to
"create a scratch assembly, insert the part twice, swx_bom, interference" is therefore
not achievable on this machine, and swx_bom / swx_interference / per-component
mass properties remain mock-only. Re-test them on a classic desktop install, or against
an assembly the user already has.
Section B: the cloud is not a stub, it is a documented absence
The brief said "build swx_cloud_status + swx_cloud_search + swx_cloud_open ONLY if
you can prove ONE call live". Nothing is provable: IsPLMConnected and
GetPLMConnection do not exist on this build, and GetSearchFolders / EnumDocuments2
are rejected. So only swx_cloud_status ships — and it RUNS THAT PROBE ON DEMAND and
returns the results (probedMembers), so the answer is evidence rather than an assertion.
No swx_cloud_search/swx_cloud_open stubs: a verb that can only fail is worse than no
verb, because an AI will keep trying it.
Section A: dialogs are censused, not dismissed
The brief asked for swx_dismiss_blocking_dialogs that classifies and closes. Half of
that was a mistake, learned the hard way: closing a stuck SOLIDWORKS modal through ab's
UIA WindowPattern.close took the whole application down. So the verb REPORTS by
default; closing needs close:true + reason + confirm; and a dialog classified
neverAutoAnswer (Save changes?, Document Recovery, licence) is never closed at all.
Attribution is honest too: ab's flat window list carries no owner pid, so a bare #32770
is reported with ownerConfirmed:false rather than silently claimed as SOLIDWORKS'.
The COM-reported Frame().GetHWnd() (proven live) is what identifies the app window
exactly.
Additions the brief did not ask for, that the work demanded
tools/swx-batch.py. Eachshell_executeround trip to the desktop costs 10-20s; discovering a late-bound signature takes dozens of probes. Batching them into one round trip is what made live-first development practical. (It stages the call list with ab'swrite_file— inlining it as anEncodedCommandblows the Windows command-line limit.)swx_action_ledgeris the audit log; the SWEEP list is separate and in memory. The first cut hadswx_reset_workspaceread the ledger fordocument_createdtitles. That is unsafe: SOLIDWORKS names new documentsPart1/Assem1, so a title recorded days ago would eventually match an unsaved document the USER made this morning, and the sweep would close it. The registry now lives inCtx::scratch_docs(in memory, per-process) and is emptied byswx_close_doctoo; a bridge restart forgets, so the sweep closes nothing rather than the wrong thing. The ledger keeps being the durable record of what happened — which is what it is for.nameas a document handle. A document fromswx_new_documenthas no path until it is saved, soswx_close_doc/ the com host'sresolve()now accept a title. (Watch the namespace collision:swx_set_parameterandswx_set_preferencealso takename, meaning something else entirely — those resolve their document frompathalone.)swx_stop/swx_killrefuse on unsaved work, which the brief did not specify. Quitting a CAD application with dirty documents strands it on a save dialog nobody can answer, and on Connected editions the bridge cannot restart it.
Smaller hardening from the self-review pass
- A failed sketch step used to leave SOLIDWORKS in sketch-edit mode.
sketch_rect/sketch_circleopen a sketch, draw, then close it; the draw used?, so an error returned with the sketch still open and a half-drawn profile on the user's screen.open_sketchnow returns the SketchManager it opened and both ops close it on every path before propagating the error. swx_kill's unsaved-document probe can itself fail when COM is wedged — the exact case the verb exists for. It now reportsunsavedKnown:falseand a null count instead of a reassuring zero.swx_get_parameterswalks the feature tree asking each feature for its dimensions, at a COM round trip apiece. Bounded to 500 features withtruncatedreported, so a thousand-feature model cannot turn a read verb into a minute of blocked CAD.
What is still owed
How to finish it (the exe is already built and served on :8877 from
target/x86_64-pc-windows-gnu/release/; restart that server there if it has died):
# 0. the user must start SOLIDWORKS from the 3DEXPERIENCE Platform first — the
# bridge cannot, and must not try.
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" shell_execute '{"command":"powershell -NoProfile -Command \"Invoke-WebRequest -Uri https://drew2-gallia-7g17dw3vho0f.adom.cloud/proxy/8877/swx-bridge.exe -OutFile \\\"$env:LOCALAPPDATA\\Adom Bridge\\bridges-cache\\solidworks-dev\\swx-bridge.exe\\\" -UseBasicParsing\"","timeoutSeconds":180,"reason":"..."}'
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" process_kill '{"pid":<old swx-bridge pid>,"reason":"..."}'
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" launch_process '{"path":"%LOCALAPPDATA%\\Adom Bridge\\bridges-cache\\solidworks-dev\\swx-bridge.exe","args":["--port","24695"],"window":"hidden","env":{"ADOM_BIND_HOST":"127.0.0.1"},"reason":"..."}'
python3 tools/live-smoke.py
tools/live-smoke.py is written and has not been run: Drew's desktop dropped off the
ab relay before the v0.3 exe could be redeployed. Nothing in v0.3 has been executed
end-to-end against SOLIDWORKS — only the COM sequences underneath it, individually,
through v0.2's swx_com_eval. The suite is the thing that closes that gap; it needs the
desktop back and SOLIDWORKS started from the 3DEXPERIENCE Platform.
Build notes — 2026-09-02 (round 2: adversarial review)
The review found that three of the four headline safety guarantees were bypassable, two of them proven by running the binary. It was right about all three, and about a fourth that was worse: the live-smoke suite itself was the most dangerous artifact in the repo.
The four bypasses, and why each one existed
The VRML block tested the wrong noun. The guard checked the format ARGUMENT; SaveAs3
keys the export off the DESTINATION EXTENSION. So swx_export_step {outputPath:"x.wrl"}
went straight into the call that hung SOLIDWORKS for 110 seconds — and because the
per-format aliases hard-code format, the guard could never fire for them at all. Fixed on
the extension, in three places (server, save_as3, DSL parse) because there are three roads
to SaveAs3 and the DSL road had already been shown to skip a check. Tested the way it
breaks, on all five roads.
swx_run_script had no notion of whose document it was building into. validate(ops, has_doc) accepted a script with no new_part whenever anything was open, so a sketch and an
extrude landed in the user's part. Worse, the verb's own _hint recommended
swx_close_doc {save:false} as the undo — i.e. discard their document. There is now an
ownership gate (Ctx::scratch_docs is the authority) on swx_run_script, swx_set_parameter,
swx_save_as-as-rename and swx_close_doc-on-dirty-work, overridable only with
confirm:true AND a reason, which is recorded in the ledger. The hint no longer offers a
destructive undo for a document that is not ours.
DSL save_as bypassed safe_out and renamed in place. The standalone verb had always
validated its destination; the DSL reached SaveAs3 by a different road. Now validated at
parse time (absolute, no .., not a blocked extension), written in Copy mode, and refusing
to overwrite. A test asserts the two blocked-extension lists cannot drift apart.
Dialogs and windows were closed on a guess. is_solidworks_window marks any top-level
#32770 as a candidate with ownerConfirmed:false, and the close path ignored that flag —
so swx_dismiss_blocking_dialogs {close:true} could have closed Word's Save dialog. The unit
test even encoded an unattributed "Export" dialog as closable. Both fixed; the regression
test now asserts the opposite of what it used to.
The launch guard was a hope, not a guard
One untested path substring, on a registry value never observed on the target machine, that
answered "desktop" — meaning launch it — for anything unrecognised. Now three independent
signals (install path, registry version strings, the 3DEXPERIENCE Launcher's presence),
"desktop" requires POSITIVE evidence, and "unknown" fails safe. Both real layouts are
unit-tested, and SWX_TEST_NO_LAUNCH makes the host_app_launch_blocked branch reachable
from a test for the first time — it had never executed anywhere.
live-smoke.py was a data-loss path
Phase 1 batched new_document → run_script → set_parameter → props_set → save_as; the
PowerShell loop had no abort-on-failure and main() never checked whether new_document
succeeded. On a Connected edition — where a failure is entirely plausible — every later step
would have hit the ACTIVE document, then save_as (plain Silent) would have renamed and
relocated the user's part into a scratch folder, cleanup would have closed it save:false,
and Remove-Item -Recurse -Force would have deleted the folder.
Rewritten around structure rather than care: a Doc registry of what the suite created,
mutate() as the only door to a mutating verb (refusing anything not ours), Abort on any
preflight or setup failure, stop_on_failure in the batch runner so a dead step stops the
trip, per-file deletion instead of a recursive tree removal, and the document-set invariant
as a GATE that runs even on an early abort. It also now proves the refusals — including that
a .wrl never reaches COM, which is the one that would hang the user's SOLIDWORKS.
Rebutted, with reasons
swx_props_set is NOT ownership-gated, deliberately. It is in a different risk class from
swx_run_script: it writes a named metadata field the caller explicitly specified, in memory,
reversible by not calling swx_save — and annotating the user's existing parts with
PartNo/Material is the bridge's core BOM workflow, not an accident. Gating every property
write behind confirm would make the most common legitimate use unusable while preventing
nothing that swx_save does not already make explicit. It is a v0.2 verb with a v0.2
contract, and this is a decision rather than an oversight.
fusion_export_f3d stays ➖ not applicable while skp/fbx/usdz moved to ❌ lacking.
F3D is Autodesk's own archive format — SOLIDWORKS will never write it, and no translator
could exist. The others are formats with real translators that this install simply does not
have, which is a gap rather than an impossibility.
Smaller items
Import can no longer run twice (the fallback is now decided by whether a document actually
appeared, not by the shape of LoadFile4's return). stop matches only the specific
RPC-disconnect HRESULTs and re-probes GetActiveObject to VERIFY the application went away.
get_parameters is bounded by COM round trips, not just feature count. Sketch ops clear the
selection before and after. The DSL pins its target document instead of re-resolving "whatever
is active now". Timeout budgets raised to cover worst-case server paths. bridge.json
detect paths synced with the code's globs. Stale catalog summaries fixed.
The coverage gate now requires an ASSERTION per verb, not just a call — the harness credits
each check! to the most recent verb, and the gate fails on any shipped verb that was POSTed
and never looked at. Mock fidelity: real untitled names (Assem1, not Assembly1),
swFileSaveError_e 256 for formats a part cannot produce, and injectable rename so
report-bug #2 has coverage for the first time.
Build notes — 2026-09-02 (round 3: adversarial review)
The centrepiece of round 2 — the ownership gate — had a Critical flaw, and it is the most instructive defect of the whole build.
targetDocument authorized one document and the bridge mutated another
The gate resolved its target from targetDocument and decided ownership from that. But only
swx_run_script FORWARDED the argument; swx_save_as, swx_set_parameter, swx_props_set
and swx_export all sent path (usually null), so the com host fell back to ActiveDoc.
With a user part in front, swx_save_as {outputPath:"…", targetDocument:"Part1"} passed the
gate on Part1 and then renamed and relocated the USER'S document — the v0.2 data-loss bug,
resurrected by its own fix. The refusal message even handed the caller the value that
triggered it, in bridgeDocuments.
The lesson is not "check harder", it is that a check and the action it guards must not be
able to disagree about their subject. The fix is structural: resolve_target() produces a
Target ONCE, and Target::args() is the only way a mutating verb addresses a document —
docName (a dedicated key that cannot collide with the name meaning a dimension on
set_parameter or a preference on set_preference), plus the path. Whatever the gate
approved is literally what the com host is told to open. A targetDocument naming nothing
open is document_not_open, never a fall back; naming two different documents two ways is
ambiguous_target.
Verified against the running binary with the reviewer's exact call: Part1 is renamed,
IMPORTANT.sldprt is untouched.
Rebuttal, stated because the review asked for a different test. The requested case was
"retry with targetDocument from bridgeDocuments → must STILL refuse". It does not, and
should not: with the fix, naming Part1 operates on Part1, which is correct and safe. The test
that ships asserts the thing that actually matters — the retry acts on the named document AND
the user's document is provably still open and unchanged — plus the ambiguity and
not-open refusals that close the door the old bug went through.
Ownership failed OPEN on title reuse
Entries were keyed on title alone, and forgotten only on paths that run through this bridge —
never by CloseAllDocuments, a crash, or the user closing a document in the SOLIDWORKS UI.
The comment claiming "a restart forgets" was simply false: the bridge SERVER outlives
SOLIDWORKS. And SOLIDWORKS restarts its Part1/Assem1 numbering with every session, so a
remembered Part1 would eventually match a fresh Part1 the user made — which the bridge
would then treat as its own and build geometry into, ungated.
An entry is now (title, SOLIDWORKS session, path-once-saved), where the session is
SOLIDWORKS' own process id. Ownership additionally requires the document to be OPEN RIGHT NOW,
which is what covers every close path the bridge cannot see. An unknown session fails CLOSED.
The mock models both facts — a new process id and a reset Part counter after stop/start —
so the suite can prove it rather than assert it.
Two more unguarded roads to the same COM calls
swx_open passed launch:true unconditionally: the only such call outside swx_start, and
untouched by every previous fix. On a Connected edition one swx_open would have put the
"launch me from the 3DEXPERIENCE Platform" modal back on Drew's screen — the exact incident
this whole effort exists to prevent. The server now makes that decision with may_launch.
swx_com_eval reached SaveAs3 directly, skipping the guarded helper, the extension block,
Copy mode and the ownership gate. It now refuses the members that write, close or delete. A
denylist is the right shape here: an allowlist of readable members would have to enumerate the
SOLIDWORKS API to be useful, while the members that DESTROY are a short, well-known list.
The persist step is where permission belongs
Round 2 rebutted gating swx_props_set on the grounds that it is in-memory. That argument
only holds if nothing can commit it — and swx_save was ungated in the same surface. The
rebuttal was hollow, so swx_save is gated now (confirm + reason on a document the bridge did
not create, recorded in the ledger) and props_set stays open, which keeps the BOM workflow
usable while closing the path to the user's file.
Smaller, and one that was embarrassing
The extension guard was a raw string compare: a.wrl., a.wrl and a.wrl::$DATA all
resolve to a.wrl under Windows canonicalisation and all sailed through. Now normalised.
Window attribution treated a title merely CONTAINING "SOLIDWORKS" as proof of ownership, which
the Installation Manager and any 3DEXPERIENCE launcher dialog also satisfy — and with COM down
the title is the ONLY test, precisely when being wrong is most likely; a real owner-pid match
is now required. close_all_documents {closeUnsaved:true} discarded every dirty document on
one flag; it now needs a reason, discards only the bridge's own, and lists the user's in
skipped. Import fails closed when the document count is unknowable. The coverage gate binds
each assertion to the verb its NAME mentions rather than the last verb called.
live-smoke, again
Three more holes: swx-batch.py called sys.exit(1) on a staging failure, and SystemExit
is not Abort — it would have sailed straight through the finally that runs the
"documents unchanged" gate, so the one run where the invariant matters most was the run that
skipped it. Fixed by returning None and catching BaseException. mutate() checked
ownership once per phase while ActiveDoc follows window focus, so the eight-export batch is
now eight calls with the active document re-read before each. And phase_ownership_refusal
fired three mutating verbs at Drew's real document to prove a gate that had just been shown
capable of failing open — it now proves the same refusal without aiming anything mutating at
his data, unless --touch-user-doc is passed.
The suite's docstring now says what round 3 made explicit: restart the bridge process before a live run, so ownership starts empty rather than inheriting a stale claim.
Build notes — 2026-09-03 (round 4: adversarial review)
Round 3's Critical was half fixed, and the half that was missing was the important half.
A title is not an identity
Ownership was (title, session), and Target::args() sent docName:<title> which the com host
preferred over the path the Target was already carrying. Two open documents named
thing.sldprt in different folders is completely ordinary. Reproduced: with the user's dirty
C:\users\thing.sldprt and the bridge's own C:\bridge\thing.sldprt both open,
swx_save_as {targetDocument:"thing.sldprt"} — no confirm, no reason — passed the gate
against the bridge's copy and renamed and relocated the USER's document.
Three changes: com.rs::resolve() prefers path over docName (a title is only the handle
for a document that has never been saved, and SOLIDWORKS will not issue the same untitled name
twice in one session); Ctx::owns_doc keys on the path wherever there is one; and a title
matching more than one open document is ambiguous_target on every verb, read or write, with
the candidates and their paths in the response.
swx_close_doc was the one destructive verb that resolved nothing
Its unsaved-work guard compared a title against scratch_titles() with no session scoping and no
pruning, and it ignored targetDocument entirely. Reproduced: it closed and discarded the user's
dirty thing.sldprt while the bridge's identically-titled document survived. It now goes through
the same resolver as everything else.
My round-3 rebuttal was unsound, and worth recording as such
I argued the "retry from bridgeDocuments must still refuse" test was wrong because naming
Part1 correctly operates on Part1. That holds only while titles are unique, which I asserted
rather than checked — and the refusal handed the AI a TITLE to retry with, which is precisely how
the collision was reached. bridgeDocuments now carries {targetDocument, path} per entry and
the hint says to retry with the path, not the title. The e2e asserts (title, path) IDENTITY
of the user's document, not that it is still open: in the bug it WAS still open — under a new
name, at a new path, inside the bridge's folder.
The denylist was the wrong shape
swx_com_eval blocked members that write. It did not block SendMsgToUser2 (a modal on the
user's screen that wedges the com host for the full timeout — the exact disruption class this
whole effort exists to prevent), SetUserPreferenceToggle, SetSaveFlag, ForceRebuild3 or
EditRebuild3, because nobody thought of them. A denylist has to anticipate every dangerous
member of an API with thousands. It is now an allowlist of reads: Get*/Is*/Has*/Can*/
Enum* plus a short list of navigation properties. A member nobody has thought of is refused, and
the message says how to add it if it is genuinely a read.
live-smoke was still a weapon
Three more, all in the file whose entire job is to be safe:
phase_ownership_refusalwas not opt-in as I had claimed — only therun_scriptvariant was gated; the default still sent a realswx_save_asat the user's own document throughcall(), on the premise that "a refusal is a read". C1 made that premise false. The default now proves the refusal against scratch documents and error paths that name nothing real.cleanup()unconditionally deletedstolen.sldprt— a file it never writes. If the gate had failed, that file is the user's relocated document, and the suite would haveRemove-Item -Forced it out from under an open SOLIDWORKS window, destroying both the document and the evidence. It now deletes only names inFILES_WRITTEN, and the existence ofstolen.sldprtis a hard FAIL that says not to delete it.- The invariant gate was the LAST statement in cleanup, after
listing()and a dozenshell()round trips, any of which could raise into the outer handler — so the runs that most needed "documents unchanged" were the runs that skipped it. It now runs FIRST, in its own try, and SKIPS rather than fails when no baseline was captured (a preflight abort fires before the baseline exists, and a spurious failure there teaches the reader to ignore the one check that matters).
Also: the units probe and the taskbar badge register their restore BEFORE changing anything, so an
abort or a dropped relay cannot leave the user's settings flipped; mutate()'s docstring no longer
claims to be the only door (batch() sends two vetted-target mutating calls, and says so); and the
resolver reads the open-document list ONCE instead of a COM round trip per document with the active
document re-read each time.
The pattern, stated plainly
Four rounds, and every defect was a guard that existed and could be walked around — never a
guard nobody had thought of. Three times the fix introduced the next one. Whatever is still wrong
is most likely the same shape, which is the argument for tools/live-smoke.py over any further
assurance from me.
Build notes — 2026-09-03 (round 5: the design change)
Five rounds, five Criticals, all in one surface: the bridge writing to something the user
owns, through a route whose guard had been forgotten. Round 5 found two more — an ungated
Save3 behind close_doc {save:true}, and no overwrite protection on three of four write
destinations. At that point the strategy, not the code, was what needed replacing.
Writes are deny-by-default, at one choke point
src/writes.rs::authorize is the only thing that decides whether this bridge may write.
Every write goes through it and it refuses unless BOTH: the document is one this bridge
created in this SOLIDWORKS session, AND the destination path does not already exist. The
escape is SWX_ALLOW_USER_WRITES=1 on the process PLUS allowUserWrites:true + confirm +
reason on the call, and every grant is ledgered.
Three properties make this different from the four previous attempts:
- It is one function, not N guards. A new write verb cannot forget to call it and still work, because it has no other way to reach the com host with a mutating command.
- The com host holds an independent veto. It keeps its own record of the documents it
created (it creates them, so it knows) and reads the opt-in from the environment it
inherited — not from anything the server said. Rounds 3 and 5 both turned on the
server-authorizes / host-acts split; a server-side check is a claim. Now two independent
systems must agree, and
the_com_host_refuses_a_write_the_server_would_have_alloweddrives the raw stdio protocol to prove the host says no to a forged authorization. - The default is the safe subset, and the suite runs in it.
live-smoke.pypasses with the opt-in OFF, which is the strongest available statement that read + create + model + export-to-new-paths is genuinely the whole useful surface.
The MOCK backend models the host veto too. A mock that omits the security-relevant behaviour makes the test covering it prove nothing — the lesson of every previous round.
Also fixed
resolve_target and resolve_target_for_read collapsed into ONE resolve_document:
identity is now a separate concern from permission, which is why the choke point could be
made total. Ambiguity is enforced on PATH as well as title (save_as can put two open
documents at the same path, so round 4's defect was re-expressible with the key that replaced
it). Every document-addressing verb — bom, mass_properties, interference,
rebuild_check, props_get, props_set, document_info, design_info, get_parameters,
inspect_bodies, get_preferences — now goes through it; a BOM off the wrong same-titled
part is a bill of materials somebody orders from.
swx_props_set no longer writes a reason THE BRIDGE AUTHORED FOR ITSELF into the ledger
("swx_props_set is in-memory until swx_save, which is gated") — a self-authored justification
in the user's audit trail, and one that the close_doc{save:true} bypass made false. It
records an honest user_document_properties_set entry instead. swx_close_doc now records
in the ledger at all, which both docs had promised and neither delivered.
live-smoke, round 5
ab() caught only json.loads, so a subprocess.TimeoutExpired propagated out of
cleanup() past the invariant gate AND every restore — on exactly the runs (wedged
SOLIDWORKS, dropped relay) where putting the machine back matters most. The invariant now
runs FIRST in its own try, with atexit and SIGTERM/SIGINT handlers so a kill cannot skip it.
The landmine check (stolen.sldprt and friends) now HALTS deletion and returns instead of
printing and carrying on into Remove-Item eight lines later. FILES_WRITTEN records
authorship rather than intent, and preflight ABORTS if the shared C:\Users\Public\swx-smoke
is non-empty, since nine fixed filenames could otherwise collide with somebody else's.
The honest summary
Four rounds of route-by-route gating did not converge. Denying by default at one choke point, enforced twice, removes the class rather than the instance. That should have been the design from the start, and the reason it was not is worth stating: each individual gate looked sufficient when it was written.
Build notes — 2026-09-03 (round 6: applying the design completely)
Round 5's design was right and its APPLICATION was not. authorize was wired into seven
verbs; three more wrote around it, and one of those destroyed a file on disk in the default
posture. The lesson repeats the shape of every previous round — a guard that exists, and a
route that does not reach it — with one difference: this time the fix is structural on the
host side rather than another verb-by-verb sweep.
The host now authorizes on its DISPATCH LOOP, keyed off what is safe
The first host veto covered a remembered list of seven commands. Proven by forged raw stdio:
a forged save was correctly vetoed while a forged run_script — the entire modelling DSL,
InsertSketch/FeatureExtrusion3/SetMaterialPropertyName2/Add3 — with no
targetDocument resolved to the user's ActiveDoc and mutated it. props_set,
rebuild_check, eval, close_doc{save:false}, close_all_documents and stop were
equally absent.
Backend::authorize_command is now part of the trait and the host's main loop calls it for
every command not on READ_ONLY_COMMANDS. Listing what is SAFE rather than what is dangerous
is the whole point: a command nobody has classified is refused, and one added tomorrow is
deny-by-default. eval additionally re-checks the read allowlist in the host, because the one
verb that can call any COM member had no host-side guard at all.
The three routes that wrote around the choke point
swx_bom {format:"csv"}didstd::fs::writeon the SERVER with no authorization anywhere —safe_outonly checks "absolute, no..". It overwrote a pre-existing file and returnedsuccess:true. Because the write is server-side, both "independent vetoes" were structurally irrelevant to it. And SKILL called the verb a read.swx_props_setwas left ungated on the argument that it is "in memory until a gated save". That ignored the persist route that cannot be gated: the user. It leaves their document dirty, so SOLIDWORKS prompts "Save changes?" on close and their own Ctrl+S commits bridge-authored data. Modifying the document is the harm, whoever eventually writes it.swx_rebuild_checkcalledForceRebuild3freely — a member on this bridge's OWNNAMED_HAZARDSlist, whichcom_evalrefuses as "rebuilds the user's model". A first-class verb doing what the escape hatch forbids is not a defensible split.
Session changes are writes too
swx_stop, swx_close_all_documents and swx_close_doc on a document the bridge did not
create now need the process-level opt-in. Closing someone's document costs them their window,
their undo history and their place in the work even when it is clean; quitting is close to a
one-way door on a Connected edition, which this bridge cannot restart. Kind::EndSession is
gated unconditionally — there is no foreign document and no clobbered file to trigger on.
Smaller, and one that was mine to notice
swx_pack_and_go had the server and the host DISAGREEING: the server passed
destination:None for an empty directory, which skipped the ownership question entirely,
while the real com host refused. The verb was dead on a real machine and green in the suite —
and the mock had no authorize in pack_and_go at all, so the tests proved the opposite of what
would happen live. create_dir_all also ran BEFORE authorization, leaving a directory on the
user's disk after a refusal. All three fixed, and Kind::CollectFiles now touches_document,
because Pack and Go copies the whole reference tree — a bulk extraction of the design, not one
export.
ComBackend::created was per-process, and the com host is killed and respawned BY DESIGN.
So a respawn started refusing writes to the BRIDGE'S OWN documents: fail-closed, but silently
broken, exactly after the recovery path the architecture exists for. It is persisted now
(comhost/created_store.rs), written and read only by the HOST so the veto stays independent,
and cleared when the SOLIDWORKS session ends.
On the reversion claim
I reported "stubbing the choke point fails 2 tests". That was measured with a broken mutation
and was wrong in both directions — and there was no test at all for five of the routes above.
Every route now has one, verified by mutating writes::authorize to wave that verb through:
bom 1, props_set 2, rebuild_check 2, pack_and_go 2, stop/close_all 2, save 2, close_doc 2,
run_script 3, set_parameter 2, set_preference 2, export 1; the host dispatch veto 2.
live-smoke
One real hole: swx_rebuild_check {} sat in phase_build's batch, targeted the ACTIVE
document, and was not in MUTATING, so mutate() never vetted it. If Drew clicked his own
model mid-batch, ForceRebuild3 would have run on it. It is pinned to the scratch document,
moved out of the batch, and added to MUTATING along with bom and pack_and_go. Preflight
also now asserts the DEFAULT posture, because the suite passing without the opt-in is the
strongest statement that the safe subset is genuinely useful.
Build notes — 2026-09-03 (round 7: the half that never speaks to the com host)
Round 6 closed the write surface — every mutating command forged over raw stdio was refused by the host's own veto. Round 7 found the remaining hole in the OTHER half of the bridge: the verbs that go out through ab, where the com host's veto is not in the path at all and the server-side choke point was never wired in.
swx_kill force-killed SOLIDWORKS in the default posture
Its gates were confirm:true + reason — both things a caller writes for itself — plus an
unsaved-documents guard that force:true overrode AND that silently no-opped when COM was
wedged (unsaved_known=false → unsaved=0 → guard skipped), which is exactly the case the
verb exists for. Proven against an ab stub: taskkill /F /IM SLDWORKS.exe dispatched.
It goes through writes::authorize as Kind::EndSession now, so the process-level opt-in is
required exactly as it is for swx_stop. And kill_is_unsafe treats an unanswerable probe as
UNSAFE: an unanswerable question is not permission. That decision is a pure function with its
own unit test, because a mock cannot make COM fail convincingly.
swx_close_window would close the application FRAME
ownerConfirmed is true precisely when a window is most provably SOLIDWORKS' — and the main
window is the most provable window there is. So the "this is not a general window killer"
guard PASSED on the one window it must never pass on, and mainHwnd is handed to callers by
swx_status and swx_window_info, which makes it the obvious thing to try. UIA Close on a
frame quits the application, with Save-changes modals on the way out.
The COM-reported main hwnd is now refused before ab is consulted at all (otherwise an
unreachable ab gives window_not_attributed — the right answer for the wrong reason, which
would flip to allowed the moment ab could see the window), and any non-dialog window class is
treated as a frame. swx_window_info reports isFrame and closable per window.
Every dialog close now needs the opt-in — including the bridge's own
The reviewer asked for a narrow bridge-caused allowlist. I built it, then took it out. The
argument against my own first answer: on 2026-09-02 closing a stuck SOLIDWORKS modal through
the window manager TOOK THE WHOLE APPLICATION DOWN. A verb that can do that should not act on
confirm + reason. BRIDGE_CAUSED_DIALOGS survives as a REPORTED flag — a caller can see
which dialogs are the bridge's own mess — but it licenses nothing.
The test gap that let all of this survive six reviews
With no ab reachable, the ab-delegated verbs only ever ran their "ab is unreachable" branch.
A guard on that side could be deleted and every test would stay green. common::AbStub is a
real HTTP stand-in for ab's direct API that reports windows WITH pids (so attribution
genuinely succeeds) and RECORDS what was dispatched — because "the refusal returned an error"
and "nothing was sent" are different claims, and only the second one matters. The new e2e
asserts ab.destructive_calls().is_empty().
Smaller
created_store now stamps the SOLIDWORKS session, and a record from another session confers
nothing (round 6 traded fail-closed for fail-open here: the file was cleared only on a clean
stop, so a crash, a kill or the user quitting left it claiming their next Part1). The mock
had the same bug in reverse — it keyed its "SOLIDWORKS session" to the com host's own pid, so
a respawned host saw a different SOLIDWORKS and discarded its own record; the session now
lives in a file, as a real process id does. pack_and_go's two vetoes were answering
different questions (server: "is the directory non-empty?", host: exists()); both use
existence now. swx_open is READ-ONLY by default, so the bridge does not hold a write lock on
the user's file and an older-version document is not converted-and-dirtied just by being
looked at.
READ_ONLY_COMMANDS keeps show, activate_document and interference, and now says why in
the code: they change the VIEW, not the work — nothing they touch reaches disk, geometry or
saved state, and interference's three try_puts are on a manager object that Done() tears
down on every path via a drop guard. A list called "read only" containing them without that
note would have been a lie; gating them would make swx_show_* useless on the user's own
documents, which is the one thing it is for.
live-smoke
M2: the default-posture check was check(), not require() — with the env var set the
suite would have recorded one FAIL and then run the whole mutating suite in the ENABLED
posture. It is the single precondition the live run rests on, and it is a gate now. M3:
set_preference escaped mutate(), had no targetDocument, and so aimed at whatever was
ACTIVE — the same class of miss as round 6's rebuild_check. Pinned, routed through
mutate(), and its RESTORE lambda pinned too.
Build notes — 2026-09-03 (round 8: the sibling that was never gated)
Round 7 gated swx_kill and taught swx_close_window to refuse application frames. It did not
gate swx_close_window itself — so of the four session-enders, three went through the choke
point and one did not.
swx_close_window closed the user's "Save changes?" modal, in the default posture
No writes::authorize_in, and no neverAutoAnswer check, while
swx_dismiss_blocking_dialogs — operating on the SAME window list — had both. Proven with a
recording ab stub: posture disabled, {hwnd: 9001, reason:"tidying", confirm:true} on
"Save changes to gearbox?" → success:true, desktop_ui_window {action:"close"} dispatched.
The impact is the largest this bridge can cause. By its own live evidence from 2026-09-02,
closing a modal SOLIDWORKS is blocked on takes the whole application down — and this is the
modal that stands between the user and every unsaved document they have. It was also
DISCOVERABLE: swx_window_info reported that dialog closable:true, the same shape as round
7's mainHwnd.
The reasoning I used to justify removing the bridge-caused-dialog allowlist in round 7 —
"a verb that can do that should not act on confirm+reason, both of which a caller writes for
itself" — applied verbatim to this verb, and I applied it only to the sibling. Both refusals
are now on both verbs, and closable folds in ALL THREE (frame, attribution, never-auto-answer)
so a caller can act on the field rather than re-deriving the rules.
is_frame_window inverted its own fail-safe
!class.is_empty() && class != DIALOG_CLASS — a window ab reports with no className was
therefore "not a frame", and closable. Proven: a SOLIDWORKS-pid window titled bracket.SLDPRT
with no class was closed and dispatched. It is class != DIALOG_CLASS now: unknown means
frame, the same rule kill_is_unsafe applies to an unanswerable probe.
Three of round 7's seven fixes introduced their own defect
swx_open is the third. Round 7 changed the DEFAULT to read-only and left readOnly:false
caller-settable — on the com host's safe list, where authorize_command never runs. By the
comment I wrote in the same commit, that takes a write lock on the user's file and
converts-and-dirties an older-version document in memory. is_mutating now takes the args, so
open {readOnly:false} is authorized on both sides; the host decides ownership on the PATH
against its own record.
That pattern is worth naming: the fixes that introduced defects were all fixes that changed a DEFAULT while leaving the dangerous mode reachable. Changing a default is not closing a hole.
swx_screenshot was a general desktop capture tool
titleContains was forwarded straight to ab, so {"titleContains":"1Password"} captured
whatever matched, from a verb catalogued as "screenshot the SOLIDWORKS window". Not a write,
but it is the user's data and it had no test. It captures BY HANDLE now — the one COM reports,
or an hwnd swx_window_info attributes to SOLIDWORKS.
swx_uninstall could erase the audit log
It takes no gate (ab calls it on app uninstall) and removed ledger.jsonl with the rest of the
scratch subtree — so an AI could erase the record of what it did by asking the bridge to tidy
up. The ledger is now the one artifact uninstall leaves behind; removing the folder is the
user's to do.
Ownership means "open AND ours", on both sides
Writing the readOnly test surfaced a disagreement I had introduced: the host's persisted
record kept a path after the document closed, while the server's registry pruned it. So a
closed bridge-created file was ours to the host and not to the server. The host now drops the
entry on close_doc too. The rule both sides state: a document this session created and STILL
HOLDS — not a file this bridge once wrote, which is the reading that could drift onto someone
else's file after a rename.
Mutation results
Twelve targeted mutations, twelve killed, including the four the review found unkilled. The
three that needed new coverage were all cases the stub made unreachable: the main-hwnd shortcut
(every stub frame also had a non-dialog class), the ownerConfirmed guard (every stub window
carried the SOLIDWORKS pid, so nothing was ever unattributable), and the host's
is_mutating("open") (the server refused first, so the host's veto was never exercised). The
lesson is the same one as round 7: a stub that only ever presents the easy case tests the easy
case.
Also done: the interference Done() drop guard is armed BEFORE the three try_puts, so a
panic inside a put cannot leak the session; the dead isFrame check in dismiss is a real one;
live-smoke's swx_show_drawing is pinned; and the e2e assertion that accepted
window_not_attributed as an alternative to window_is_application_frame — and so pinned
nothing — now requires the frame code.
Build notes — 2026-09-03 (round 9: the first live run)
The suite ran against Drew's real SOLIDWORKS. 22 passed, 4 failed, aborted — and the four failures were all one functional bug, in the exact place eight rounds of security review never looked.
What held up
Everything the previous eight rounds built. Drew had his own Part1 open; the suite created
Part2, never touched Part1, aborted on the first failure, removed every file it wrote, and
left SOLIDWORKS running. swx_reset_workspace reported
skipped: [{title:"Part1", why:"not created by this bridge in this SOLIDWORKS session"}] —
session-scoped ownership, working on a real machine against a real user's document. The
"documents unchanged" invariant correctly FAILED and named the orphan. The
host_app_launch_blocked finding was confirmed live: "this IS the Connected edition, which
cannot be launched programmatically."
The bug: an unsaved document was unaddressable by every verb
swx_docs listed Part2; swx_design_info, swx_close_doc and swx_reset_workspace all
reported "document not open: Part2" — including with no target at all, on the ACTIVE
document.
Root cause: GetOpenDocumentByName wants a FULL PATH. An unsaved document has none, so it
cannot be found by title — and since round 3 the server ALWAYS sends a resolved identity, so
even the no-target case went through it instead of falling back to ActiveDoc. The whole
create → model → export flow — the thing Drew actually asked for — was dead for new documents.
The host now resolves a TITLE by walking GetDocuments() and comparing GetTitle(). That
works for saved and unsaved alike and lets the ambiguity rule be enforced at the host rather
than trusting whichever document SOLIDWORKS hands back first. Paths still win where a path
exists.
My round-3 rebuttal was right for this case, and the round-4 fix over-corrected. I argued then that a pathless document's title IS its identity because SOLIDWORKS will not issue the same untitled name twice in a session. Round 4 found a real title-collision bug in SAVED documents and I made path the identity everywhere — including where there is no path. Both halves are now true at once: path first when there is one, title when there is not.
Why 89 mock tests passed
The mock resolved titles out of its own map, so it could never express the failure. It now
mirrors the real resolution exactly: a PATH matches only a document that actually has one
(reported_path is "" until saved, and the internal key is not a path), and a TITLE is
resolved by walking the open documents. That change alone makes the old behaviour fail 9 tests.
This is the fourth time a mock that modelled the easy case hid a real defect. The rule I should have been applying: the mock must model the API's CONSTRAINTS, not just its results.
Also
HostError carries a branchable code now, propagated through the protocol into
errorCode — the live failures came back with errorCode: None and prose only, and a caller
cannot branch on "document not open: Part2". document_not_open, ambiguous_target and
no_target_document are the codes.
live-smoke.py's cleanup retries a close by the title SOLIDWORKS is actually reporting, and if
a document still cannot be closed it says so in one line, names it, and says plainly that it is
the suite's own unsaved scratch — not the user's. The live run left an orphan Part2 and said
nothing about it beyond a failed check.
An honest limit
com.rs is cfg(windows) and never executes in the mock suite, so no Linux test can kill a
mutation in it — I verified this by mutating the title-walk and watching every test stay green.
Its correctness rests on the cross-compile, on the mock mirroring its logic, and on the live
run. That is the gap the live run exists to close, and it is why the run found this and the
suite did not.
Build notes — 2026-09-03 (round 10: live run 2)
120 passed, 9 failed, no abort — up from 22-and-aborted. The create → model → export flow
ran end to end on real hardware, which makes report-bugs #1 and #2 PROVEN FIXED rather than
diagnosed: eight formats exported with saveErrorCode clean, documentRenamed:false, the title
unchanged and real bytes on disk. The Silent|Copy path has now actually executed.
The bug: one letter of case
swx_save_as was asked for …\smoke.sldprt. SOLIDWORKS reported the document at
…\smoke.SLDPRT. The ownership record kept the path we ASKED for; prune_scratch compared it
against the path SOLIDWORKS REPORTED; the exact string compare failed; the bridge disowned the
scratch document it had just created and then refused to close or clean it up. Four of the nine
failures cascaded from that, and an orphan was left on the machine.
Two fixes, because either alone would leave the class open:
same_pathcompares Windows paths the way Windows does — case-insensitively, with separators normalised. Used byowns_doc,prune_scratch,by_path, and both backends'host_owns.- The record stores the path SOLIDWORKS REPORTS, not the one we asked for.
And the principle the reviewer named, which is the actual lesson: ownership follows the
DOCUMENT, not the string that used to name it. A non-copy save_as retitles and re-paths in
place, so the host moves its claim with it; remember_created now evicts by IDENTITY rather
than by title, which is what let an imported document displace the record of a saved one that
happened to share its name.
This is the mirror image of round 9's bug. Both came from identity being DERIVED at each use instead of TRACKED through the document's life.
Three mock-fidelity gaps, all of the same family
The mock could not express any of this, which is why 89 tests passed:
- It did not uppercase the extension on save, so requested and reported paths always matched exactly.
- It titled an imported document by bare stem, not
<stem>.SLDPRT, so it could not produce the title collision the live run hit. - Its
openduplicated an already-open document instead of returning it, an artifact that masked the ownership defect in the one test that came closest.
Fixing (1) alone broke three tests immediately — those were the tests that should have caught the live bug. Fourth round running, the same lesson: the mock must model the API's behaviour and constraints, not just its happy-path results.
ambiguous_target was correct, and the situation was the bridge's fault
swx_import of the STEP the suite had just exported produced a second document with the same
title, so close-by-title refused — right answer, self-inflicted problem. Close-by-PATH now
resolves before the title-ambiguity check can fire (a path identifies exactly one document),
and live-smoke imports from a different stem so it stops manufacturing the collision.
"ab is unreachable" must not read as "there are no windows"
Three failures were ADOM_DIRECT_API_URL not in env — the dev bridge was launched by hand, so
it never inherited ab's direct-API environment. swx_window_info returned [], which is
indistinguishable from a real absence and made a launch artifact look like three bridge bugs.
It now returns windows: null with abAvailable:false and a hint naming the cause; swx_status
reports abAvailable up front; and live-smoke SKIPS the ab-dependent checks with a clear note
rather than failing them.
Build notes — 2026-09-03 (round 11: live run 3)
125 passed, 5 failed (22-and-aborted → 120/9 → 125/5). The ownership fix held: save_as
retitles and the document stays ours, so everything the disown-on-save cascade had blocked now
runs — CreateCircleByRadius, FeatureCut4 and SetUserPreferenceInteger, all three previously
shipped-but-never-executed, plus complete file cleanup.
The bug: swx_import registered ownership on the server but not on the host
Three of the five failures were this one cause. cmd_import has THREE success paths — LoadFile4
returning a dispatch, a handle recovered from ActiveDoc, and the LoadFile2 fallback — and only
the first called remember_created. The live machine took one of the others, so the host's
independent veto refused to close the document the bridge had just imported.
Same shape as run 2's bug, one layer down: an ownership record that does not follow the document through an operation the bridge itself performed.
The fix is structural, per the reviewer's ask. Registration is no longer a per-verb call. The
dispatch loop reads the {created} / {imported} document out of any successful result and
registers it via Backend::register_created, so no branch of no command can forget — and a verb
added tomorrow is registered by existing into the same result shape. opened is deliberately NOT
in CREATED_KEYS: opening the USER'S file does not make it ours, and a mutation adding it fails
two tests.
That is the third time a defect has come from identity being handled per-site rather than in one place. The pattern across runs 1-3 is worth stating plainly: all three live defects were ownership records failing to follow a document through an operation the bridge performed, and none was a safety failure — the deny-by-default posture held throughout, and in every case the bridge failed CLOSED, refusing to touch a document rather than touching the wrong one.
A test-isolation defect found while fixing it
Two raw-stdio host tests spawned a com host with no SWX_SCRATCH_DIR, so they used the
developer's real scratch root — and the com host PERSISTS its created-document record. They
therefore inherited whatever had run before them, and passed or failed by accident; a mutation
run was what exposed it. Both have isolated scratch dirs now, and the suite is verified
repeatable across consecutive runs.
The ab surface is now honestly reported as untested
The last two failures were deployment, not the bridge: a hand-launched exe never inherits
ADOM_DIRECT_API_URL. The preflight probe and swx_dismiss_blocking_dialogs now SKIP with a
note naming the cause, so a hand-launched bridge scores a clean sheet and the ab-delegated
surface reads as UNTESTED rather than broken — which is what it is.
Re-grading
Nineteen verbs move to ✅ live-proven on run-3 evidence. Two that the evidence does NOT support
were held back: fusion_close_window (its ab path was SKIPPED, not exercised) and fusion_state
(it ran with screenshot:false, so the render-me half is still untested). Overclaiming those
would be exactly the failure this report exists to avoid.
Build notes — 2026-09-03 (round 12: the launch answer, and an escape hatch nobody could open)
Three defects and one capability. Version 0.3.1 — a new verb and a new capability change the surface, so lockstep moved.
The gate was closed in the wrong direction
Drew authorized closing every leftover document from three live runs, including his own
blank Part1. The bridge was restarted with SWX_ALLOW_USER_WRITES=1, every call
carried allowUserWrites + confirm + reason, and it closed 3 of 5:
swx_close_doc→ "the com host refuses to close_doc: this document was not created by this bridge"swx_close_all_documents {closeUnsaved:true}→ skipped the dirty foreign documents unconditionallyswx_stop {force:true}→success:true, stopped:false, declined silently
Twelve rounds have been spent making sure this bridge cannot write to somebody's data by accident. This round is the other half of that, and it took three live failures to see it: an escape hatch nobody can open is not a safety property, it is a bug. Every one of these had the same cause — the server AUTHORIZED the action and then failed to tell the host, or told it something weaker than what it had checked itself.
swx_close_doc built a Grant only on the save:true branch, so close_doc {save:false} on a foreign document reached the com host carrying no evidence of the
permission at all; the host, holding its own independent veto, correctly refused. And the
host's veto re-checked allowUserWrites alone while the server checked all three
elements — so "two independent vetoes" was not two applications of one test, it was a
strong test and a weak one that happened to disagree in the direction that blocked the
user. Both sides now call writes::missing_opt_in, one function, and a Grant carries
the whole chain to the host so it has the same evidence to re-check.
swx_stop was the same class of dishonesty pointed the other way: the host does the
right thing (it re-attaches after ExitApp and reports that SOLIDWORKS is still there)
and the server dressed that up as a success. It is now host_app_stop_declined, with the
host's own reason preserved and a hint that points at the unanswered "Save changes?"
modal — explicitly not at swx_kill, which would discard exactly the work the dialog
is asking about.
Two more found while proving the fixes
A refused bulk close was closing things. swx_close_all_documents ran its discard
loop and only then asked whether it was allowed to end the session — so in the default
posture it closed the bridge's own dirty documents, discarding their contents, and
returned a refusal. Side effects on the way to "no". Authorization moved to the top,
where it also supplies the grant the per-document closes need; the reordering is now
structurally enforced, because a mutation that moves it back does not compile.
A document SOLIDWORKS will not answer for was being called clean. The dirty flag was
read with unwrap_or(false), so a document whose state could not be established never
entered the discard loop, CloseAllDocuments(false) then left it alone because it did in
fact need saving, and it came back in the host's remaining — left open by a call told to
close everything, with nothing anywhere saying why. That is one of the two documents live
run 3's cleanup could not shift. Same rule as everywhere else now: an unanswerable
question is not permission. Every document still open when the verb returns is named in
skipped with a reason, reconciled from the host's own after-the-fact view rather than
from the server's expectations.
The mock could not report unknown dirtiness at all, which is why that guard had no test — the fourth time this round-by-round pattern has repeated: the mock modelled the easy case and hid a real defect.
THE LAUNCH ANSWER
For two rounds this bridge reported that SOLIDWORKS Connected "cannot be launched
programmatically", on live evidence: CoCreateInstance spawns a process that puts up
"SOLIDWORKS Design must be launched from the 3DEXPERIENCE Platform" and never attaches.
The evidence was real and the conclusion was wrong, because it was drawn from the wrong
experiment. The Start-Menu shortcut the Platform installs does not run sldworks.exe:
CATSTART.exe -run "SWXDesktopLauncher.exe"
-object "--AppName=\"SWXCSWK_AP\" -tenant=<TENANT> -monoapp
-3DRegistryURL=https://<region>-makers-registry.3dexperience.3ds.com"
-nowindow
The refusal was ours, for bypassing the launcher chain. swx_start now invokes that
shortcut — deliberately the .lnk itself and never a hand-assembled CATSTART command
line, because the shortcut is the sanctioned artefact and carries that user's own tenant,
whereas a command line we built would be a guess at it.
Design notes worth keeping: the shortcut is chosen by a ranked pure function, not
"first .lnk found", because a Start-Menu group holds an uninstaller and a pile of side
tools next to the application, and no shortcut at all resolves to Blocked (which asks
the user) rather than to a guess. The route decision does not require installed, since a
shortcut IS evidence of an install and the exe globs miss the Connected layouts. And the
attach poll is bounded — a cold Connected start runs 1-3 minutes and the com host is
the server's only channel, so holding it would freeze swx_progress and every other verb;
the first version waited 45 seconds and did exactly that to its own test suite. It answers
started:true, stillLaunching:true, never a failure, because a failure invites a second
launch and that means two sign-in windows on somebody's desktop.
host_app_launch_blocked now means one thing only: no shortcut could be found.
swx_run_macro — scaffolding, labelled as such
ISldWorks::RunMacro2, written from the documented signature. NOT run live; the response
says provenLive:false and the report grades it accordingly, because that line is the only
thing this report is for.
Its gate is the interesting part. Every other write here is bounded by two questions — is
the document ours, is the destination clear — and a macro answers neither: it is a VBA
project with the whole API in scope and the filesystem behind it. So Kind::RunMacro is
always gated with no carve-out, and gated first, before even the existence check, so
an unauthorized caller does not learn what is on somebody's disk. What is being authorized
is arbitrary code execution on a person's desktop, and the only honest gate for that is the
human saying so each time.
The bundled resources/macros/adom-hello.swp is a placeholder that says so in its own
comments. The real template will be recorded from SOLIDWORKS' macro recorder — the only
way to be sure of the module and procedure names it expects — and notably the obvious
hello-world, SendMsgToUser2, raises a modal, which this bridge must never do.
Verification
61 verbs · 0.3.1 in lockstep · lib 74 · e2e 28 · protocol 11 (+1 ignored) · 0 failed · zero warnings on both targets · 3 consecutive clean runs · 14 mutations, 14 killed (one of them uncompilable, which is stronger than a test).
tools/live-smoke.py no longer aborts when the write opt-in is on — it could not
otherwise test the paths this round fixed — and every posture-sensitive check is explicitly
two-sided. It proves the launch route was FOUND but never fires it, because firing the
launcher while SOLIDWORKS is up is the one thing that must not happen; and there is no
posture in which it executes a macro.
# Fusion-parity brief — solidworks-bridge v0.3 (swx)
Drew's ask (2026-09-02): "Build the functionality to open an existing file or create a
new one. Learn EVERY capability of the Fusion bridge (wiki.adom.inc/adom/fusion-bridge),
build a SW alternative for all of them, and tell me what is lacking when done."
Quality bar is unchanged: kicad/pup/native-browser. Every response carries a `_hint`;
stable `errorCode`s; background etiquette; progress on slow verbs; a suite that cannot lie.
The v0.2 codebase (Rust, single exe, killable COM child) is the base — extend, don't rewrite.
## What is PROVEN LIVE on Drew's machine (2026-09-02) — build on these facts
- SOLIDWORKS 3DEXPERIENCE R2026x ("SOLIDWORKS Design Professional for Makers 2026 SP3.0",
sldworks.exe v34.3.0.0150) at
`C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\sldworks.exe`.
- **Out-of-process COM attach WORKS** (GetActiveObject → `swx_com_eval $sw.RevisionNumber()` →
"34.3.0"). PowerShell's late-binding adapter FAILS the same call (TYPE_E_ELEMENTNOTFOUND); our
raw GetIDsOfNames+Invoke path works. Never reintroduce a PowerShell layer.
- **Creating documents:** `$sw.NewPart()` HANGS (>110s, killed by the watchdog; no dialog left).
The working recipe is `NewDocument(template, 0, 0, 0)` with the stock templates:
`$sw.GetDocumentTemplate(1,"",0,0,0)` → `C:\ProgramData\SolidWorks\SOLIDWORKS 2026\templates\Part.prtdot`
`$sw.GetUserPreferenceStringValue(9)` → `...\templates\Assembly.asmdot` (swDefaultAssemblyTemplate=9;
swDefaultPartTemplate=8 returned "" — so resolve part templates via GetDocumentTemplate, and
drawing templates via GetDocumentTemplate(3,...)). Live: NewDocument(Part.prtdot) → "Part1";
swx_docs sees it; swx_mass_properties runs (mass 0 on an empty part); swx_close_doc {save:false}
discards it; swx_docs is empty again.
- No local .sldprt/.sldasm exist on the box (Connected keeps documents in the 3DEXPERIENCE cloud),
so every document-verb test must CREATE its own scratch document, exercise it, and close it
WITHOUT saving. Files saved to disk for tests go under the bridge scratch dir and are removed.
- 13/13 no-document verbs pass live; crash recovery (kill+respawn of the COM child) proven live.
- Two detection bugs were found live and are fixed (partial-component glob; case-insensitive
tasklist match). Lesson: mock-only green is not green — validate every new verb LIVE.
## Dev loop that works (use it; do not invent another)
```
cargo build --release --target x86_64-pc-windows-gnu # exe ~850K
# the container already serves target/x86_64-pc-windows-gnu/release/ on :8877 via python http.server
# -> https://drew2-gallia-7g17dw3vho0f.adom.cloud/proxy/8877/swx-bridge.exe (anonymous OK)
# on the desktop (ALWAYS --target adom_drew2; 2 bridge clients are on the relay):
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" shell_execute '{"command":"powershell -NoProfile -Command \"Invoke-WebRequest -Uri https://drew2-gallia-7g17dw3vho0f.adom.cloud/proxy/8877/swx-bridge.exe -OutFile \\\"$env:LOCALAPPDATA\\Adom Bridge\\bridges-cache\\solidworks-dev\\swx-bridge.exe\\\" -UseBasicParsing\"","timeoutSeconds":180,"reason":"..."}'
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" process_kill '{"pid":<old>,"reason":"..."}' # old dev bridge (pid was 95412)
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" launch_process '{"path":"%LOCALAPPDATA%\\Adom Bridge\\bridges-cache\\solidworks-dev\\swx-bridge.exe","args":["--port","24695"],"window":"hidden","env":{"ADOM_BIND_HOST":"127.0.0.1"},"reason":"..."}'
python3 tools/swx-live.py <verb> '<json>' [timeoutSec] # calls POST /command on the desktop via curl.exe
```
Every gated ab verb (shell_execute, launch_process, process_kill, write_file) needs a `reason`
written for the human. `tools/swx-live.py` already does this.
## HARD RULES while driving Drew's live SOLIDWORKS
- NEVER call `swx_stop` / ExitApp / process_kill on sldworks.exe. Drew is using it.
- NEVER close or save a document you did not create. Scratch docs you create are closed with
save:false. Never leave a scratch doc open at the end of a test.
- NEVER foreground SOLIDWORKS; never send keys/clicks to it. COM only.
- A verb that can hang gets a bounded timeout; the COM child watchdog is the safety net, not
the plan. Test any NEW COM call first via `swx_com_eval` with a short timeout.
- Keep the 23 existing verbs' shapes backward compatible. Bump version to 0.3.0 (Cargo.toml +
BRIDGE_VERSION + bridge.json + package.json in lockstep — lib.rs asserts it).
## The Fusion bridge's capability surface (inventory) → the SW plan
Source: wiki page adom/fusion-bridge v1.9.341 (bridge.json 57 manifest verbs; describe.py
catalog ~110 verbs; README_VERBS.md categories). Cloned at
/tmp/claude-1001/-home-adom-project/ca530728-41cc-4f93-a92d-d32d4afe1153/scratchpad/fusion-bridge/
(read VERBS.md, README_VERBS.md, describe.py, SKILL.md, README.md, docs/*.md for exact
arg/response shapes — mirror arg NAMES where the concept is the same so an AI that knows
fusion_* can drive swx_* with no relearning).
### A. Lifecycle & app state — BUILD (full parity)
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_start / fusion_launch | swx_start (exists) | add `visible`, `stillLaunching` polling semantics + measured cold/warm history (SDK progress rule) |
| fusion_stop | swx_stop (exists) | keep; refuse when `unsavedDocs>0` unless `force:true` |
| fusion_kill | swx_kill | taskkill /F sldworks.exe — gated, requires `reason` + `confirm:true` |
| fusion_readiness | swx_readiness (exists) | add `addinStale:false`-style parity fields: `comAttachable`, `version`, `hostPid` |
| fusion_get_app_state | swx_get_app_state | = today's swx_status+state merged into Fusion's shape: running, activeDocument{name,type,path,dirty}, documents[], workspace(part/assembly/drawing), busy, blockingDialogs[] |
| fusion_state | swx_state (exists) | ADD the screenshot: base64 PNG of every SOLIDWORKS window via ab desktop_screenshot_base64 {hwnd} (callback), plus dialog census |
| fusion_document_info | swx_document_info | active doc metadata: name, type, path, isSaved/dirty, configurations, units, custom-prop count |
| fusion_activate_document | swx_activate_document | `$sw.ActivateDoc3(name, useUserPref, option, errors)` — by name or path |
| fusion_close_document | swx_close_doc (exists) | add `name` arg parity; `save:false` default silent |
| fusion_close_all_documents | swx_close_all_documents | `$sw.CloseAllDocuments(includeUnsaved)`; DESTRUCTIVE → require `confirm:true`; skips dirty unless `closeUnsaved:true` |
| fusion_reset_workspace | swx_reset_workspace | close all scratch docs the bridge created (track them in a ledger) |
| fusion_dismiss_blocking_dialogs / fusion_close_window / fusion_dismiss_recovery | swx_dismiss_blocking_dialogs, swx_close_window | enumerate SOLIDWORKS-owned dialog windows (via ab desktop_list_windows callback, owner pid = sldworks), classify by title (Save changes?, Recovery, license, update), WM_CLOSE by hwnd via ab desktop verbs; never auto-answer "Save changes?" |
| fusion_window_info / screenshot_fusion / screenshot_all / take_screenshot | swx_window_info, swx_screenshot (exists) | screenshot_all = every SW-owned window; use ab generic verbs only |
| fusion_action_ledger | swx_action_ledger | append-only ledger of background actions (docs created/closed, dialogs dismissed, child respawns) in the scratch dir |
| fusion_verb_times | swx_verb_times | measured per-verb timings store (p50/p90, cold vs warm) — feeds `progress` blocks |
| fusion_live_feed | swx_live_feed | LATER (v0.4) — note as lacking |
| fusion_install_fusion / signin / signin_2fa / set_auto_update / set_seat_takeover / set_window_sizing / set_driving_badge | — | NOT APPLICABLE / DEFER: SW has no silent free installer (reseller/Makers store), sign-in is the 3DEXPERIENCE launcher's. Driving badge (taskbar overlay via ab) is CHEAP → build `swx_set_driving_badge` if time allows; report the rest as lacking with reasons |
### B. Files: open / create / save / cloud — BUILD (Drew's explicit ask)
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_open_design (local) | swx_open (exists) | already silent OpenDoc6; add `activate:false` option, `readOnly`, return `progress` (measured) |
| NEW (no Fusion twin, Drew asked) | **swx_new_document** | `{type:"part"|"assembly"|"drawing", template?, name?}` via NewDocument(template,0,0,0) with the stock templates resolved at runtime (GetDocumentTemplate). Returns doc info. Record in the scratch ledger. |
| fusion_import_step | swx_import | open STEP/IGES/Parasolid/STL as a new part via `$sw.LoadFile4(path, "r", null, errors)` (import) — live-test; fall back to OpenDoc6 for native |
| fusion_save_to_cloud | swx_save_as | `$doc.Extension.SaveAs3(path, 0, 1, null, null, errs, warns)` local save-as (Connected also supports "Save to 3DEXPERIENCE" — expose `swx_save_to_cloud` only if provable live via API; else report lacking) |
| fusion_export_{step,iges,stl,3mf,obj,dxf,dwg,sat,f3d,usdz,fbx,skp} | swx_export (exists, one verb) | add per-format aliases swx_export_step/iges/stl/3mf/dxf/dwg/pdf/parasolid so the arg surface matches; verify each format LIVE on the scratch part (SaveAs3 keys off extension; 3MF/OBJ may need swExportOptions — test; report unsupported ones as lacking) |
| fusion_aps_search / browse / recent / open / file_info / versions / download / upload / create_folder / list_cloud_projects / list_cloud_files / walk / search_cloud_files | swx_cloud_* | **RESEARCH + STUB**: 3DEXPERIENCE has the 3DSpace/3DDrive REST APIs (tenant https://oi000616940-usw1-makers-ifwe.3dexperience.3ds.com, CAS/passport auth). This is the Fusion APS trail. Build `swx_cloud_status` (auth state) + `swx_cloud_search` + `swx_cloud_open` (OpenDoc7 / PLMObjectSpecification via COM) ONLY if you can prove ONE call live; otherwise ship a clear `errorCode:"cloud_not_configured"` scaffold + document exactly what auth/API is needed. Report as lacking. |
| fusion_cleanup_cloud_files / delete_cloud_file / export_cloud_file | — | lacking (cloud) |
| fusion_check_recovery | swx_check_recovery | detect the SOLIDWORKS Document Recovery pane/dialog |
| fusion_find (n/a) | swx_find_files (exists) | keep |
| fusion_pack_and_go (n/a) | swx_pack_and_go (exists) | live-test it |
### C. Design intelligence & modeling — BUILD (the MCAD strength)
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_get_design_info | swx_design_info | feature count, body count, sketch count, units, configurations, material, referenced files (assemblies), rebuild state |
| fusion_get_parameters / set_parameter | swx_get_parameters / swx_set_parameter | global variables + dimensions: `$doc.GetEquationMgr()` (Equation, GetCount, Equation(i) get/put) and `$doc.Parameter(name)` / IDimension.SystemValue. Live-test on a scratch part with one extruded box (create via swx_run_script? see below) |
| fusion_inspect_bodies | swx_inspect_bodies | per body: bbox (mm), volume, area, faceCount, material, visible — `$doc.GetBodies2(0,true)` + IBody2.GetMassProperties / GetBodyBox |
| fusion_physical_properties | swx_mass_properties (exists) | add per-component breakdown for assemblies (parity with Fusion's per-component list) |
| fusion_assembly_bom | swx_bom (exists) | add `indented:true` (level column, parent path), `kitAware` no-op, and CSV column ordering parity |
| fusion_run_modeling_script / execute_text_command | swx_run_script | THE escape hatch with teeth: a small, safe, deterministic modeling DSL executed on the COM child — at minimum `new_part`, `sketch_rect(w,h,plane)`, `extrude(depth)`, `set_material(name)`, `set_prop(k,v)`, `save_as(path)` so tests can build a real solid (the mass-properties/BOM/export tests NEED a body). Implement via the standard API: SketchManager.InsertSketch / CreateCornerRectangle / FeatureManager.FeatureExtrusion3 (test the exact late-bound signatures live; document the ones that work). Also keep swx_com_eval. |
| fusion_mcp_* (Autodesk MCP proxy) | — | NOT APPLICABLE (no SOLIDWORKS MCP from Dassault); note the community MCP servers exist (research from 2026-09-01) — lacking by design |
| fusion_board_stackup / electronics | — | not applicable (MCAD) |
### D. Preferences & UI — BUILD (cheap parity)
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_get_preferences / set_preference | swx_get_preferences / swx_set_preference | units (GetUserPreferenceIntegerValue swUnitsLinear), theme not applicable (report), background/appearance where API allows; keep the set small and PROVEN |
| fusion_prefs_open / prefs_close | — | lacking (UI-driven) |
| fusion_click_fusion / send_key | — | deliberately NOT built (COM-first; foreground etiquette). Report as lacking-by-design with the etiquette rationale |
### E. Show / eight-surfaces analog — BUILD the MCAD set
| Fusion | SW verb | Notes |
|---|---|---|
| fusion_show_project / show_2d_board / show_3d_board / show_schematic | swx_show_part, swx_show_assembly, swx_show_drawing | open+activate+zoom-to-fit (`$doc.ViewZoomtofit2()`) in the BACKGROUND, return via swx_state screenshot |
| fusion_show_symbol / footprint / 3d_package / library | — | not applicable (electronics) |
### F. Manufacturing / electronics / libraries / demo — mostly NOT APPLICABLE
gerbers, CPL, design rules, layers, EAGLE/Electron commands, .lbr libraries, managed libraries,
package generation, molecule/GLB pipeline, fusion_demo tour: report each as not applicable
to MCAD or as "lacking, candidate for v0.4" (e.g. swx_export_drawing_pdf, swx_demo tour).
## Testing (app-testing discipline, adapted) — REQUIRED
1. Mock parity: every new verb gets a MockBackend implementation with the same response shape,
e2e coverage in tests/e2e.rs, and the verb-coverage gate keeps passing (bridge.json ⇔
describe ⇔ calls made, three ways). No test reads source as strings.
2. LIVE smoke suite: `tools/live-smoke.py` (python, runs from the container via
tools/swx-live.py) that: creates a scratch part via swx_new_document → builds a real solid via
swx_run_script → exercises design_info, parameters get/set, inspect_bodies, mass_properties,
props get/set, export (step, stl, iges, pdf-for-drawing where possible) with files under the
scratch dir → verifies bytes on disk → creates a scratch assembly, inserts the part twice
(if provable) → swx_bom → interference → closes everything without saving → asserts swx_docs
is back to the pre-run set → prints a PASS/FAIL table. It must leave Drew's SOLIDWORKS exactly
as it found it. The Verifier runs this suite; the Builder runs it before claiming done.
3. Error paths for every new verb (missing args, wrong doc type, unsafe paths).
## Deliverables
- Code + tests green (`cargo test`, cross-compile), version 0.3.0, committed.
- Updated README.md / SKILL.md / docs (verb families, the NewDocument recipe, cloud status).
- **docs/FUSION-PARITY-REPORT.md**: for EVERY Fusion capability (all ~110 catalog verbs): SW verb
built (live-proven ✓ / mock-only ⚠) | adapted | NOT APPLICABLE (why) | LACKING (why + what it
would take). Drew reads this; be honest and specific.
- Append "## Build notes — <date>" to this brief for every plan deviation.
## Build notes — 2026-09-02 (v0.3 build)
Every deviation from the plan above, with the assumption that turned out to be wrong.
The full verdict-per-verb picture is in `docs/FUSION-PARITY-REPORT.md`; this is the
record of where the BRIEF was wrong, which is a different (and more useful) list.
### The brief's biggest wrong assumption: that SOLIDWORKS can be started
Section A said `swx_start (exists)` and asked for polling semantics. It cannot exist on
this machine. `CoCreateInstance(SldWorks.Application)` returns `CO_E_SERVER_EXEC_FAILURE`
(`0x80080005`), and launching `sldworks.exe` directly spawns a process that puts up
*"SOLIDWORKS Design must be launched from the 3DEXPERIENCE Platform or from the desktop
shortcut created from the Platform"* and never becomes a drivable COM server. Both were
observed live. `GetActiveObject` attach is the only working path.
`swx_start` is now attach-first and, when nothing is attachable, returns
`errorCode:"host_app_launch_blocked"` with instructions written for the human. The
CoCreateInstance path survives for classic desktop SOLIDWORKS, behind
`Detection::edition()` / `can_launch()` (the install path carries `3DEXPERIENCE` /
`Dassault Systemes`). `swx_stop` now refuses while documents are unsaved and its hint
warns that on Connected it cannot start the app again — stopping is close to a one-way
door here, which the brief did not anticipate.
**Cost of learning this:** one stray refused `sldworks.exe` (identified by the pid
`launch_process` returned, and removed) and one modal briefly on the user's screen. The
constraint is now encoded in code, SKILL.md, README.md and the report so it cannot be
rediscovered the same way.
### Four v0.2 COM bugs the live probes found (none visible to 49 mock tests)
1. **`SaveAs3` returns `swFileSaveError_e`, not a bool.** `0` is success. v0.2 read it as
a bool, so every successful export reported failure. Probes wrote a 15,836-byte STEP
and a 22,304-byte IGES that the bridge called refusals. Now decoded, bit flags and all.
2. **Exports were renaming the user's open document.** `swSaveAsOptions_Silent` (1) alone
is a real Save As. Observed live: a scratch part came back titled `probe.SLDPRT` after
an export. Exports now use `Silent|Copy` (3), and `swx_export` asserts the title
survived (`documentRenamed`).
3. **`InterferenceDetectionManager` is on `IAssemblyDoc`, not `ModelDocExtension`.** Every
interference check in v0.2 failed with "unavailable". Confirmed live on the assembly doc.
4. **`Feature::GetErrorCode2` is uncallable late-bound on this build** (it wants a single
byref BSTR; every arity we can express is rejected), so v0.2's feature walk marked
EVERY feature unreadable — an empty assembly reported 16 errors. `GetErrorCode()` (no
args) works and returned 0 live; `Extension.GetWhatsWrongCount()` is the cross-check.
### Where the brief's proposed COM calls were wrong or incomplete
- **`GetUserPreferenceStringValue(8)` DOES work** (the brief said it returned ""). All
three template preferences (8/9/10) and all three `GetDocumentTemplate(1|2|3,...)` calls
resolve on this install. `template_for()` tries GetDocumentTemplate first and falls back.
- **`SelectByID` (the 5-arg legacy form) is enough** for plane selection and is proven
live. The brief implied `Extension.SelectByID2` with its null callout argument; the
legacy form needs no null, so there is one less thing to marshal wrong. (`null` was
added to the `swx_com_eval` grammar anyway — it is needed for `LoadFile4`.)
- **`FeatureExtrusion3` takes 23 arguments and consumes the sketch that `InsertSketch`
just closed** — no re-selection needed. Proven live end to end: `NewDocument` →
`SelectByID("Front Plane","PLANE",0,0,0)` → `InsertSketch(true)` →
`CreateCornerRectangle` → `InsertSketch(true)` → `FeatureExtrusion3(...)` →
`GetBodies2(0,true)` returned 1 body, and `swx_mass_properties` read back exactly
3.0e-5 m³ for a 50×30×20 mm box.
- **`GetMaterialPropertyName2` needs a byref BSTR** for its second parameter; the
two-plain-string form is rejected. `SetMaterialPropertyName2(config, db, name)` works
as documented (mass went from 0.03 kg at density 1000 to 0.081 kg at 2700).
- **`ISldWorks::NewPart()` hangs** — confirmed again; `NewDocument` is the only route.
### Section B: export formats, measured rather than assumed
The brief asked for per-format aliases and said to verify each live. Done, by exporting a
real solid and checking the bytes:
- **Work from a part:** STEP, STP, IGES, STL, X_T, X_B, SAT, 3MF, **GLB**, PLY, AMF,
3DXML, PNG, JPG, TIF, XAML, PDF.
- **Refused for a part** (`swFileSaveError_e` 256, nothing written): OBJ, DXF, DWG, U3D.
DXF/DWG are drawing formats; aliases still ship for them because a `.slddrw` is the
right target and could not be tested.
- **VRML (`.wrl`) HANGS**: it raises a modal option sheet, the COM call never returns, and
the watchdog killed the host after 110s — leaving the dialog on screen. `swx_export`
now refuses it before it reaches COM (`errorCode:"format_blocked"`). This is a
capability the brief did not anticipate needing to *remove*.
GLB working natively is a genuine advantage over Fusion, which needs an optimisation
service for its GLB. Ten aliases ship (step, iges, stl, 3mf, glb, sat, parasolid, pdf,
dxf, dwg); OBJ deliberately does not, because it does not work here.
### Section C: `swx_run_script` is a typed DSL, not a script host
The brief called for "a small, safe, deterministic modeling DSL", and that is right — but
worth stating why it is not a port of `fusion_run_modeling_script`: Fusion hosts Python
in-process, SOLIDWORKS' scripting is VBA (a macro *file* format driven through a UI), so
there is nothing to `eval`. `script` is a JSON array of typed steps, parsed and validated
on the SERVER before the first COM call — a script that cannot work never half-runs on a
live session. Millimetres in, metres at the COM boundary, one conversion point.
`cut` (`FeatureCut4`) ships but was never executed live and says so in its result
(`proven:false`).
### Section C: the assembly verbs could not be live-proven — and why
`IAssemblyDoc::AddComponent5` returns `Nothing` on this install for every combination
tried: all `swAddComponentConfigOptions_e` values, a saved and an unsaved assembly, an
open and a closed part, and the older `AddComponent4`. No error, no dialog. The most
likely explanation is that SOLIDWORKS **Connected** does not permit local-file component
references (documents belong in 3DSpace), but that is inference. The brief's plan to
"create a scratch assembly, insert the part twice, swx_bom, interference" is therefore
**not achievable on this machine**, and `swx_bom` / `swx_interference` / per-component
mass properties remain mock-only. Re-test them on a classic desktop install, or against
an assembly the user already has.
### Section B: the cloud is not a stub, it is a documented absence
The brief said "build `swx_cloud_status` + `swx_cloud_search` + `swx_cloud_open` ONLY if
you can prove ONE call live". Nothing is provable: `IsPLMConnected` and
`GetPLMConnection` do not exist on this build, and `GetSearchFolders` / `EnumDocuments2`
are rejected. So only `swx_cloud_status` ships — and it RUNS THAT PROBE ON DEMAND and
returns the results (`probedMembers`), so the answer is evidence rather than an assertion.
No `swx_cloud_search`/`swx_cloud_open` stubs: a verb that can only fail is worse than no
verb, because an AI will keep trying it.
### Section A: dialogs are censused, not dismissed
The brief asked for `swx_dismiss_blocking_dialogs` that classifies and closes. Half of
that was a mistake, learned the hard way: closing a stuck SOLIDWORKS modal through ab's
UIA `WindowPattern.close` **took the whole application down**. So the verb REPORTS by
default; closing needs `close:true` + `reason` + `confirm`; and a dialog classified
`neverAutoAnswer` (Save changes?, Document Recovery, licence) is never closed at all.
Attribution is honest too: ab's flat window list carries no owner pid, so a bare `#32770`
is reported with `ownerConfirmed:false` rather than silently claimed as SOLIDWORKS'.
The COM-reported `Frame().GetHWnd()` (proven live) is what identifies the app window
exactly.
### Additions the brief did not ask for, that the work demanded
- **`tools/swx-batch.py`.** Each `shell_execute` round trip to the desktop costs 10-20s;
discovering a late-bound signature takes dozens of probes. Batching them into one round
trip is what made live-first development practical. (It stages the call list with ab's
`write_file` — inlining it as an `EncodedCommand` blows the Windows command-line limit.)
- **`swx_action_ledger` is the audit log; the SWEEP list is separate and in memory.**
The first cut had `swx_reset_workspace` read the ledger for `document_created` titles.
That is unsafe: SOLIDWORKS names new documents `Part1`/`Assem1`, so a title recorded
days ago would eventually match an unsaved document the USER made this morning, and the
sweep would close it. The registry now lives in `Ctx::scratch_docs` (in memory,
per-process) and is emptied by `swx_close_doc` too; a bridge restart forgets, so the
sweep closes nothing rather than the wrong thing. The ledger keeps being the durable
record of what happened — which is what it is for.
- **`name` as a document handle.** A document from `swx_new_document` has no path until it
is saved, so `swx_close_doc` / the com host's `resolve()` now accept a title. (Watch the
namespace collision: `swx_set_parameter` and `swx_set_preference` also take `name`,
meaning something else entirely — those resolve their document from `path` alone.)
- **`swx_stop` / `swx_kill` refuse on unsaved work**, which the brief did not specify.
Quitting a CAD application with dirty documents strands it on a save dialog nobody can
answer, and on Connected editions the bridge cannot restart it.
### Smaller hardening from the self-review pass
- **A failed sketch step used to leave SOLIDWORKS in sketch-edit mode.**
`sketch_rect` / `sketch_circle` open a sketch, draw, then close it; the draw used `?`,
so an error returned with the sketch still open and a half-drawn profile on the user's
screen. `open_sketch` now returns the SketchManager it opened and both ops close it on
every path before propagating the error.
- `swx_kill`'s unsaved-document probe can itself fail when COM is wedged — the exact case
the verb exists for. It now reports `unsavedKnown:false` and a null count instead of a
reassuring zero.
- `swx_get_parameters` walks the feature tree asking each feature for its dimensions, at
a COM round trip apiece. Bounded to 500 features with `truncated` reported, so a
thousand-feature model cannot turn a read verb into a minute of blocked CAD.
### What is still owed
**How to finish it** (the exe is already built and served on :8877 from
`target/x86_64-pc-windows-gnu/release/`; restart that server there if it has died):
```bash
# 0. the user must start SOLIDWORKS from the 3DEXPERIENCE Platform first — the
# bridge cannot, and must not try.
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" shell_execute '{"command":"powershell -NoProfile -Command \"Invoke-WebRequest -Uri https://drew2-gallia-7g17dw3vho0f.adom.cloud/proxy/8877/swx-bridge.exe -OutFile \\\"$env:LOCALAPPDATA\\Adom Bridge\\bridges-cache\\solidworks-dev\\swx-bridge.exe\\\" -UseBasicParsing\"","timeoutSeconds":180,"reason":"..."}'
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" process_kill '{"pid":<old swx-bridge pid>,"reason":"..."}'
adom-bridge --target adom_drew2 --ai-thread "SolidWorks Bridge" launch_process '{"path":"%LOCALAPPDATA%\\Adom Bridge\\bridges-cache\\solidworks-dev\\swx-bridge.exe","args":["--port","24695"],"window":"hidden","env":{"ADOM_BIND_HOST":"127.0.0.1"},"reason":"..."}'
python3 tools/live-smoke.py
```
`tools/live-smoke.py` is written and has **not been run**: Drew's desktop dropped off the
ab relay before the v0.3 exe could be redeployed. Nothing in v0.3 has been executed
end-to-end against SOLIDWORKS — only the COM sequences underneath it, individually,
through v0.2's `swx_com_eval`. The suite is the thing that closes that gap; it needs the
desktop back and SOLIDWORKS started from the 3DEXPERIENCE Platform.
## Build notes — 2026-09-02 (round 2: adversarial review)
The review found that three of the four headline safety guarantees were bypassable, two of
them proven by running the binary. It was right about all three, and about a fourth that
was worse: the live-smoke suite itself was the most dangerous artifact in the repo.
### The four bypasses, and why each one existed
**The VRML block tested the wrong noun.** The guard checked the `format` ARGUMENT; `SaveAs3`
keys the export off the DESTINATION EXTENSION. So `swx_export_step {outputPath:"x.wrl"}`
went straight into the call that hung SOLIDWORKS for 110 seconds — and because the
per-format aliases hard-code `format`, the guard could never fire for them at all. Fixed on
the extension, in three places (server, `save_as3`, DSL parse) because there are three roads
to `SaveAs3` and the DSL road had already been shown to skip a check. Tested the way it
breaks, on all five roads.
**`swx_run_script` had no notion of whose document it was building into.** `validate(ops,
has_doc)` accepted a script with no `new_part` whenever anything was open, so a sketch and an
extrude landed in the user's part. Worse, the verb's own `_hint` recommended
`swx_close_doc {save:false}` as the undo — i.e. discard their document. There is now an
ownership gate (`Ctx::scratch_docs` is the authority) on `swx_run_script`, `swx_set_parameter`,
`swx_save_as`-as-rename and `swx_close_doc`-on-dirty-work, overridable only with
`confirm:true` AND a `reason`, which is recorded in the ledger. The hint no longer offers a
destructive undo for a document that is not ours.
**DSL `save_as` bypassed `safe_out` and renamed in place.** The standalone verb had always
validated its destination; the DSL reached `SaveAs3` by a different road. Now validated at
parse time (absolute, no `..`, not a blocked extension), written in Copy mode, and refusing
to overwrite. A test asserts the two blocked-extension lists cannot drift apart.
**Dialogs and windows were closed on a guess.** `is_solidworks_window` marks any top-level
`#32770` as a candidate with `ownerConfirmed:false`, and the close path ignored that flag —
so `swx_dismiss_blocking_dialogs {close:true}` could have closed Word's Save dialog. The unit
test even encoded an unattributed "Export" dialog as closable. Both fixed; the regression
test now asserts the opposite of what it used to.
### The launch guard was a hope, not a guard
One untested path substring, on a registry value never observed on the target machine, that
answered `"desktop"` — meaning *launch it* — for anything unrecognised. Now three independent
signals (install path, registry version strings, the 3DEXPERIENCE Launcher's presence),
`"desktop"` requires POSITIVE evidence, and `"unknown"` fails safe. Both real layouts are
unit-tested, and `SWX_TEST_NO_LAUNCH` makes the `host_app_launch_blocked` branch reachable
from a test for the first time — it had never executed anywhere.
### live-smoke.py was a data-loss path
Phase 1 batched new_document → run_script → set_parameter → props_set → save_as; the
PowerShell loop had no abort-on-failure and `main()` never checked whether `new_document`
succeeded. On a Connected edition — where a failure is entirely plausible — every later step
would have hit the ACTIVE document, then `save_as` (plain Silent) would have renamed and
relocated the user's part into a scratch folder, cleanup would have closed it `save:false`,
and `Remove-Item -Recurse -Force` would have deleted the folder.
Rewritten around structure rather than care: a `Doc` registry of what the suite created,
`mutate()` as the only door to a mutating verb (refusing anything not ours), `Abort` on any
preflight or setup failure, `stop_on_failure` in the batch runner so a dead step stops the
trip, per-file deletion instead of a recursive tree removal, and the document-set invariant
as a GATE that runs even on an early abort. It also now proves the refusals — including that
a `.wrl` never reaches COM, which is the one that would hang the user's SOLIDWORKS.
### Rebutted, with reasons
**`swx_props_set` is NOT ownership-gated, deliberately.** It is in a different risk class from
`swx_run_script`: it writes a named metadata field the caller explicitly specified, in memory,
reversible by not calling `swx_save` — and annotating the user's existing parts with
PartNo/Material is the bridge's core BOM workflow, not an accident. Gating every property
write behind `confirm` would make the most common legitimate use unusable while preventing
nothing that `swx_save` does not already make explicit. It is a v0.2 verb with a v0.2
contract, and this is a decision rather than an oversight.
**`fusion_export_f3d` stays ➖ not applicable** while `skp`/`fbx`/`usdz` moved to ❌ lacking.
F3D is Autodesk's own archive format — SOLIDWORKS will never write it, and no translator
could exist. The others are formats with real translators that this install simply does not
have, which is a gap rather than an impossibility.
### Smaller items
Import can no longer run twice (the fallback is now decided by whether a document actually
appeared, not by the shape of LoadFile4's return). `stop` matches only the specific
RPC-disconnect HRESULTs and re-probes `GetActiveObject` to VERIFY the application went away.
`get_parameters` is bounded by COM round trips, not just feature count. Sketch ops clear the
selection before and after. The DSL pins its target document instead of re-resolving "whatever
is active now". Timeout budgets raised to cover worst-case server paths. `bridge.json`
detect paths synced with the code's globs. Stale catalog summaries fixed.
The coverage gate now requires an ASSERTION per verb, not just a call — the harness credits
each `check!` to the most recent verb, and the gate fails on any shipped verb that was POSTed
and never looked at. Mock fidelity: real untitled names (`Assem1`, not `Assembly1`),
`swFileSaveError_e` 256 for formats a part cannot produce, and injectable rename so
report-bug #2 has coverage for the first time.
## Build notes — 2026-09-02 (round 3: adversarial review)
The centrepiece of round 2 — the ownership gate — had a Critical flaw, and it is the most
instructive defect of the whole build.
### `targetDocument` authorized one document and the bridge mutated another
The gate resolved its target from `targetDocument` and decided ownership from that. But only
`swx_run_script` FORWARDED the argument; `swx_save_as`, `swx_set_parameter`, `swx_props_set`
and `swx_export` all sent `path` (usually null), so the com host fell back to `ActiveDoc`.
With a user part in front, `swx_save_as {outputPath:"…", targetDocument:"Part1"}` passed the
gate on Part1 and then renamed and relocated the USER'S document — the v0.2 data-loss bug,
resurrected by its own fix. The refusal message even handed the caller the value that
triggered it, in `bridgeDocuments`.
The lesson is not "check harder", it is that **a check and the action it guards must not be
able to disagree about their subject.** The fix is structural: `resolve_target()` produces a
`Target` ONCE, and `Target::args()` is the only way a mutating verb addresses a document —
`docName` (a dedicated key that cannot collide with the `name` meaning a dimension on
`set_parameter` or a preference on `set_preference`), plus the path. Whatever the gate
approved is literally what the com host is told to open. A `targetDocument` naming nothing
open is `document_not_open`, never a fall back; naming two different documents two ways is
`ambiguous_target`.
Verified against the running binary with the reviewer's exact call: Part1 is renamed,
`IMPORTANT.sldprt` is untouched.
**Rebuttal, stated because the review asked for a different test.** The requested case was
"retry with `targetDocument` from `bridgeDocuments` → must STILL refuse". It does not, and
should not: with the fix, naming Part1 operates on Part1, which is correct and safe. The test
that ships asserts the thing that actually matters — the retry acts on the named document AND
the user's document is provably still open and unchanged — plus the ambiguity and
not-open refusals that close the door the old bug went through.
### Ownership failed OPEN on title reuse
Entries were keyed on title alone, and forgotten only on paths that run through this bridge —
never by `CloseAllDocuments`, a crash, or the user closing a document in the SOLIDWORKS UI.
The comment claiming "a restart forgets" was simply false: the bridge SERVER outlives
SOLIDWORKS. And SOLIDWORKS restarts its `Part1`/`Assem1` numbering with every session, so a
remembered `Part1` would eventually match a fresh `Part1` the user made — which the bridge
would then treat as its own and build geometry into, ungated.
An entry is now `(title, SOLIDWORKS session, path-once-saved)`, where the session is
SOLIDWORKS' own process id. Ownership additionally requires the document to be OPEN RIGHT NOW,
which is what covers every close path the bridge cannot see. An unknown session fails CLOSED.
The mock models both facts — a new process id and a reset Part counter after `stop`/`start` —
so the suite can prove it rather than assert it.
### Two more unguarded roads to the same COM calls
`swx_open` passed `launch:true` unconditionally: the only such call outside `swx_start`, and
untouched by every previous fix. On a Connected edition one `swx_open` would have put the
"launch me from the 3DEXPERIENCE Platform" modal back on Drew's screen — the exact incident
this whole effort exists to prevent. The server now makes that decision with `may_launch`.
`swx_com_eval` reached `SaveAs3` directly, skipping the guarded helper, the extension block,
Copy mode and the ownership gate. It now refuses the members that write, close or delete. A
denylist is the right shape here: an allowlist of readable members would have to enumerate the
SOLIDWORKS API to be useful, while the members that DESTROY are a short, well-known list.
### The persist step is where permission belongs
Round 2 rebutted gating `swx_props_set` on the grounds that it is in-memory. That argument
only holds if nothing can commit it — and `swx_save` was ungated in the same surface. The
rebuttal was hollow, so `swx_save` is gated now (confirm + reason on a document the bridge did
not create, recorded in the ledger) and `props_set` stays open, which keeps the BOM workflow
usable while closing the path to the user's file.
### Smaller, and one that was embarrassing
The extension guard was a raw string compare: `a.wrl.`, `a.wrl ` and `a.wrl::$DATA` all
resolve to `a.wrl` under Windows canonicalisation and all sailed through. Now normalised.
Window attribution treated a title merely CONTAINING "SOLIDWORKS" as proof of ownership, which
the Installation Manager and any 3DEXPERIENCE launcher dialog also satisfy — and with COM down
the title is the ONLY test, precisely when being wrong is most likely; a real owner-pid match
is now required. `close_all_documents {closeUnsaved:true}` discarded every dirty document on
one flag; it now needs a reason, discards only the bridge's own, and lists the user's in
`skipped`. Import fails closed when the document count is unknowable. The coverage gate binds
each assertion to the verb its NAME mentions rather than the last verb called.
### live-smoke, again
Three more holes: `swx-batch.py` called `sys.exit(1)` on a staging failure, and `SystemExit`
is not `Abort` — it would have sailed straight through the `finally` that runs the
"documents unchanged" gate, so the one run where the invariant matters most was the run that
skipped it. Fixed by returning `None` and catching `BaseException`. `mutate()` checked
ownership once per phase while `ActiveDoc` follows window focus, so the eight-export batch is
now eight calls with the active document re-read before each. And `phase_ownership_refusal`
fired three mutating verbs at Drew's real document to prove a gate that had just been shown
capable of failing open — it now proves the same refusal without aiming anything mutating at
his data, unless `--touch-user-doc` is passed.
The suite's docstring now says what round 3 made explicit: **restart the bridge process before
a live run**, so ownership starts empty rather than inheriting a stale claim.
## Build notes — 2026-09-03 (round 4: adversarial review)
Round 3's Critical was half fixed, and the half that was missing was the important half.
### A title is not an identity
Ownership was `(title, session)`, and `Target::args()` sent `docName:<title>` which the com host
preferred over the `path` the Target was already carrying. Two open documents named
`thing.sldprt` in different folders is completely ordinary. Reproduced: with the user's dirty
`C:\users\thing.sldprt` and the bridge's own `C:\bridge\thing.sldprt` both open,
`swx_save_as {targetDocument:"thing.sldprt"}` — **no confirm, no reason** — passed the gate
against the bridge's copy and renamed and relocated the USER's document.
Three changes: `com.rs::resolve()` prefers **`path`** over `docName` (a title is only the handle
for a document that has never been saved, and SOLIDWORKS will not issue the same untitled name
twice in one session); `Ctx::owns_doc` keys on the path wherever there is one; and a title
matching more than one open document is `ambiguous_target` on **every** verb, read or write, with
the candidates and their paths in the response.
### `swx_close_doc` was the one destructive verb that resolved nothing
Its unsaved-work guard compared a title against `scratch_titles()` with no session scoping and no
pruning, and it ignored `targetDocument` entirely. Reproduced: it closed and discarded the user's
dirty `thing.sldprt` while the bridge's identically-titled document survived. It now goes through
the same resolver as everything else.
### My round-3 rebuttal was unsound, and worth recording as such
I argued the "retry from `bridgeDocuments` must still refuse" test was wrong because naming
Part1 correctly operates on Part1. That holds **only while titles are unique**, which I asserted
rather than checked — and the refusal handed the AI a TITLE to retry with, which is precisely how
the collision was reached. `bridgeDocuments` now carries `{targetDocument, path}` per entry and
the hint says to retry with the **path**, not the title. The e2e asserts `(title, path)` IDENTITY
of the user's document, not that it is still open: in the bug it WAS still open — under a new
name, at a new path, inside the bridge's folder.
### The denylist was the wrong shape
`swx_com_eval` blocked members that write. It did not block `SendMsgToUser2` (a modal on the
user's screen that wedges the com host for the full timeout — the exact disruption class this
whole effort exists to prevent), `SetUserPreferenceToggle`, `SetSaveFlag`, `ForceRebuild3` or
`EditRebuild3`, because nobody thought of them. A denylist has to anticipate every dangerous
member of an API with thousands. It is now an **allowlist** of reads: `Get*`/`Is*`/`Has*`/`Can*`/
`Enum*` plus a short list of navigation properties. A member nobody has thought of is refused, and
the message says how to add it if it is genuinely a read.
### live-smoke was still a weapon
Three more, all in the file whose entire job is to be safe:
- `phase_ownership_refusal` was **not** opt-in as I had claimed — only the `run_script` variant was
gated; the default still sent a real `swx_save_as` at the user's own document through `call()`,
on the premise that "a refusal is a read". C1 made that premise false. The default now proves
the refusal against scratch documents and error paths that name nothing real.
- `cleanup()` unconditionally deleted `stolen.sldprt` — a file it never writes. If the gate had
failed, that file **is** the user's relocated document, and the suite would have `Remove-Item
-Force`d it out from under an open SOLIDWORKS window, destroying both the document and the
evidence. It now deletes only names in `FILES_WRITTEN`, and the existence of `stolen.sldprt` is
a hard FAIL that says not to delete it.
- The invariant gate was the LAST statement in cleanup, after `listing()` and a dozen `shell()`
round trips, any of which could raise into the outer handler — so the runs that most needed
"documents unchanged" were the runs that skipped it. It now runs FIRST, in its own try, and
SKIPS rather than fails when no baseline was captured (a preflight abort fires before the
baseline exists, and a spurious failure there teaches the reader to ignore the one check that
matters).
Also: the units probe and the taskbar badge register their restore BEFORE changing anything, so an
abort or a dropped relay cannot leave the user's settings flipped; `mutate()`'s docstring no longer
claims to be the only door (`batch()` sends two vetted-target mutating calls, and says so); and the
resolver reads the open-document list ONCE instead of a COM round trip per document with the active
document re-read each time.
### The pattern, stated plainly
Four rounds, and every defect was **a guard that existed and could be walked around** — never a
guard nobody had thought of. Three times the fix introduced the next one. Whatever is still wrong
is most likely the same shape, which is the argument for `tools/live-smoke.py` over any further
assurance from me.
## Build notes — 2026-09-03 (round 5: the design change)
Five rounds, five Criticals, all in one surface: the bridge writing to something the user
owns, through a route whose guard had been forgotten. Round 5 found two more — an ungated
`Save3` behind `close_doc {save:true}`, and no overwrite protection on three of four write
destinations. At that point the strategy, not the code, was what needed replacing.
### Writes are deny-by-default, at one choke point
`src/writes.rs::authorize` is the only thing that decides whether this bridge may write.
Every write goes through it and it refuses unless BOTH: the document is one this bridge
created in this SOLIDWORKS session, AND the destination path does not already exist. The
escape is `SWX_ALLOW_USER_WRITES=1` on the process PLUS `allowUserWrites:true` + `confirm` +
`reason` on the call, and every grant is ledgered.
Three properties make this different from the four previous attempts:
- **It is one function, not N guards.** A new write verb cannot forget to call it and still
work, because it has no other way to reach the com host with a mutating command.
- **The com host holds an independent veto.** It keeps its own record of the documents it
created (it creates them, so it knows) and reads the opt-in from the environment it
inherited — not from anything the server said. Rounds 3 and 5 both turned on the
server-authorizes / host-acts split; a server-side check is a claim. Now two independent
systems must agree, and `the_com_host_refuses_a_write_the_server_would_have_allowed` drives
the raw stdio protocol to prove the host says no to a forged authorization.
- **The default is the safe subset, and the suite runs in it.** `live-smoke.py` passes with
the opt-in OFF, which is the strongest available statement that read + create + model +
export-to-new-paths is genuinely the whole useful surface.
The MOCK backend models the host veto too. A mock that omits the security-relevant behaviour
makes the test covering it prove nothing — the lesson of every previous round.
### Also fixed
`resolve_target` and `resolve_target_for_read` collapsed into ONE `resolve_document`:
identity is now a separate concern from permission, which is why the choke point could be
made total. Ambiguity is enforced on PATH as well as title (`save_as` can put two open
documents at the same path, so round 4's defect was re-expressible with the key that replaced
it). Every document-addressing verb — `bom`, `mass_properties`, `interference`,
`rebuild_check`, `props_get`, `props_set`, `document_info`, `design_info`, `get_parameters`,
`inspect_bodies`, `get_preferences` — now goes through it; a BOM off the wrong same-titled
part is a bill of materials somebody orders from.
`swx_props_set` no longer writes a reason THE BRIDGE AUTHORED FOR ITSELF into the ledger
("swx_props_set is in-memory until swx_save, which is gated") — a self-authored justification
in the user's audit trail, and one that the `close_doc{save:true}` bypass made false. It
records an honest `user_document_properties_set` entry instead. `swx_close_doc` now records
in the ledger at all, which both docs had promised and neither delivered.
### live-smoke, round 5
`ab()` caught only `json.loads`, so a `subprocess.TimeoutExpired` propagated out of
`cleanup()` past the invariant gate AND every restore — on exactly the runs (wedged
SOLIDWORKS, dropped relay) where putting the machine back matters most. The invariant now
runs FIRST in its own try, with `atexit` and SIGTERM/SIGINT handlers so a kill cannot skip it.
The landmine check (`stolen.sldprt` and friends) now HALTS deletion and returns instead of
printing and carrying on into `Remove-Item` eight lines later. `FILES_WRITTEN` records
authorship rather than intent, and preflight ABORTS if the shared `C:\Users\Public\swx-smoke`
is non-empty, since nine fixed filenames could otherwise collide with somebody else's.
### The honest summary
Four rounds of route-by-route gating did not converge. Denying by default at one choke point,
enforced twice, removes the class rather than the instance. That should have been the design
from the start, and the reason it was not is worth stating: each individual gate looked
sufficient when it was written.
## Build notes — 2026-09-03 (round 6: applying the design completely)
Round 5's design was right and its APPLICATION was not. `authorize` was wired into seven
verbs; three more wrote around it, and one of those destroyed a file on disk in the default
posture. The lesson repeats the shape of every previous round — a guard that exists, and a
route that does not reach it — with one difference: this time the fix is structural on the
host side rather than another verb-by-verb sweep.
### The host now authorizes on its DISPATCH LOOP, keyed off what is safe
The first host veto covered a remembered list of seven commands. Proven by forged raw stdio:
a forged `save` was correctly vetoed while a forged `run_script` — the entire modelling DSL,
`InsertSketch`/`FeatureExtrusion3`/`SetMaterialPropertyName2`/`Add3` — with no
`targetDocument` resolved to the user's ActiveDoc and mutated it. `props_set`,
`rebuild_check`, `eval`, `close_doc{save:false}`, `close_all_documents` and `stop` were
equally absent.
`Backend::authorize_command` is now part of the trait and the host's main loop calls it for
every command not on `READ_ONLY_COMMANDS`. Listing what is SAFE rather than what is dangerous
is the whole point: a command nobody has classified is refused, and one added tomorrow is
deny-by-default. `eval` additionally re-checks the read allowlist in the host, because the one
verb that can call any COM member had no host-side guard at all.
### The three routes that wrote around the choke point
- **`swx_bom {format:"csv"}`** did `std::fs::write` on the SERVER with no authorization
anywhere — `safe_out` only checks "absolute, no `..`". It overwrote a pre-existing file and
returned `success:true`. Because the write is server-side, both "independent vetoes" were
structurally irrelevant to it. And SKILL called the verb a read.
- **`swx_props_set`** was left ungated on the argument that it is "in memory until a gated
save". That ignored the persist route that cannot be gated: **the user**. It leaves their
document dirty, so SOLIDWORKS prompts "Save changes?" on close and their own Ctrl+S commits
bridge-authored data. Modifying the document is the harm, whoever eventually writes it.
- **`swx_rebuild_check`** called `ForceRebuild3` freely — a member on this bridge's OWN
`NAMED_HAZARDS` list, which `com_eval` refuses as "rebuilds the user's model". A first-class
verb doing what the escape hatch forbids is not a defensible split.
### Session changes are writes too
`swx_stop`, `swx_close_all_documents` and `swx_close_doc` on a document the bridge did not
create now need the process-level opt-in. Closing someone's document costs them their window,
their undo history and their place in the work even when it is clean; quitting is close to a
one-way door on a Connected edition, which this bridge cannot restart. `Kind::EndSession` is
gated unconditionally — there is no foreign document and no clobbered file to trigger on.
### Smaller, and one that was mine to notice
`swx_pack_and_go` had the server and the host DISAGREEING: the server passed
`destination:None` for an empty directory, which skipped the ownership question entirely,
while the real com host refused. The verb was dead on a real machine and green in the suite —
and the mock had no authorize in `pack_and_go` at all, so the tests proved the opposite of what
would happen live. `create_dir_all` also ran BEFORE authorization, leaving a directory on the
user's disk after a refusal. All three fixed, and `Kind::CollectFiles` now `touches_document`,
because Pack and Go copies the whole reference tree — a bulk extraction of the design, not one
export.
`ComBackend::created` was per-process, and the com host is killed and respawned BY DESIGN.
So a respawn started refusing writes to the BRIDGE'S OWN documents: fail-closed, but silently
broken, exactly after the recovery path the architecture exists for. It is persisted now
(`comhost/created_store.rs`), written and read only by the HOST so the veto stays independent,
and cleared when the SOLIDWORKS session ends.
### On the reversion claim
I reported "stubbing the choke point fails 2 tests". That was measured with a broken mutation
and was wrong in both directions — and there was no test at all for five of the routes above.
Every route now has one, verified by mutating `writes::authorize` to wave that verb through:
bom 1, props_set 2, rebuild_check 2, pack_and_go 2, stop/close_all 2, save 2, close_doc 2,
run_script 3, set_parameter 2, set_preference 2, export 1; the host dispatch veto 2.
### live-smoke
One real hole: `swx_rebuild_check {}` sat in `phase_build`'s batch, targeted the ACTIVE
document, and was not in `MUTATING`, so `mutate()` never vetted it. If Drew clicked his own
model mid-batch, `ForceRebuild3` would have run on it. It is pinned to the scratch document,
moved out of the batch, and added to `MUTATING` along with `bom` and `pack_and_go`. Preflight
also now asserts the DEFAULT posture, because the suite passing without the opt-in is the
strongest statement that the safe subset is genuinely useful.
## Build notes — 2026-09-03 (round 7: the half that never speaks to the com host)
Round 6 closed the write surface — every mutating command forged over raw stdio was refused by
the host's own veto. Round 7 found the remaining hole in the OTHER half of the bridge: the
verbs that go out through ab, where the com host's veto is not in the path at all and the
server-side choke point was never wired in.
### `swx_kill` force-killed SOLIDWORKS in the default posture
Its gates were `confirm:true` + `reason` — both things a caller writes for itself — plus an
unsaved-documents guard that `force:true` overrode AND that silently no-opped when COM was
wedged (`unsaved_known=false → unsaved=0 → guard skipped`), which is *exactly* the case the
verb exists for. Proven against an ab stub: `taskkill /F /IM SLDWORKS.exe` dispatched.
It goes through `writes::authorize` as `Kind::EndSession` now, so the process-level opt-in is
required exactly as it is for `swx_stop`. And `kill_is_unsafe` treats an unanswerable probe as
UNSAFE: an unanswerable question is not permission. That decision is a pure function with its
own unit test, because a mock cannot make COM fail convincingly.
### `swx_close_window` would close the application FRAME
`ownerConfirmed` is true precisely when a window is most provably SOLIDWORKS' — and the main
window is the most provable window there is. So the "this is not a general window killer"
guard PASSED on the one window it must never pass on, and `mainHwnd` is handed to callers by
`swx_status` and `swx_window_info`, which makes it the obvious thing to try. UIA Close on a
frame quits the application, with Save-changes modals on the way out.
The COM-reported main hwnd is now refused before ab is consulted at all (otherwise an
unreachable ab gives `window_not_attributed` — the right answer for the wrong reason, which
would flip to allowed the moment ab could see the window), and any non-dialog window class is
treated as a frame. `swx_window_info` reports `isFrame` and `closable` per window.
### Every dialog close now needs the opt-in — including the bridge's own
The reviewer asked for a narrow bridge-caused allowlist. I built it, then took it out. The
argument against my own first answer: on 2026-09-02 closing a stuck SOLIDWORKS modal through
the window manager TOOK THE WHOLE APPLICATION DOWN. A verb that can do that should not act on
`confirm` + `reason`. `BRIDGE_CAUSED_DIALOGS` survives as a REPORTED flag — a caller can see
which dialogs are the bridge's own mess — but it licenses nothing.
### The test gap that let all of this survive six reviews
With no ab reachable, the ab-delegated verbs only ever ran their "ab is unreachable" branch.
A guard on that side could be deleted and every test would stay green. `common::AbStub` is a
real HTTP stand-in for ab's direct API that reports windows WITH pids (so attribution
genuinely succeeds) and RECORDS what was dispatched — because "the refusal returned an error"
and "nothing was sent" are different claims, and only the second one matters. The new e2e
asserts `ab.destructive_calls().is_empty()`.
### Smaller
`created_store` now stamps the SOLIDWORKS session, and a record from another session confers
nothing (round 6 traded fail-closed for fail-open here: the file was cleared only on a clean
`stop`, so a crash, a kill or the user quitting left it claiming their next `Part1`). The mock
had the same bug in reverse — it keyed its "SOLIDWORKS session" to the com host's own pid, so
a respawned host saw a different SOLIDWORKS and discarded its own record; the session now
lives in a file, as a real process id does. `pack_and_go`'s two vetoes were answering
different questions (server: "is the directory non-empty?", host: `exists()`); both use
existence now. `swx_open` is READ-ONLY by default, so the bridge does not hold a write lock on
the user's file and an older-version document is not converted-and-dirtied just by being
looked at.
`READ_ONLY_COMMANDS` keeps `show`, `activate_document` and `interference`, and now says why in
the code: they change the VIEW, not the work — nothing they touch reaches disk, geometry or
saved state, and `interference`'s three `try_put`s are on a manager object that `Done()` tears
down on every path via a drop guard. A list called "read only" containing them without that
note would have been a lie; gating them would make `swx_show_*` useless on the user's own
documents, which is the one thing it is for.
### live-smoke
`M2`: the default-posture check was `check()`, not `require()` — with the env var set the
suite would have recorded one FAIL and then run the whole mutating suite in the ENABLED
posture. It is the single precondition the live run rests on, and it is a gate now. `M3`:
`set_preference` escaped `mutate()`, had no `targetDocument`, and so aimed at whatever was
ACTIVE — the same class of miss as round 6's `rebuild_check`. Pinned, routed through
`mutate()`, and its RESTORE lambda pinned too.
## Build notes — 2026-09-03 (round 8: the sibling that was never gated)
Round 7 gated `swx_kill` and taught `swx_close_window` to refuse application frames. It did not
gate `swx_close_window` itself — so of the four session-enders, three went through the choke
point and one did not.
### `swx_close_window` closed the user's "Save changes?" modal, in the default posture
No `writes::authorize_in`, and no `neverAutoAnswer` check, while
`swx_dismiss_blocking_dialogs` — operating on the SAME window list — had both. Proven with a
recording ab stub: posture `disabled`, `{hwnd: 9001, reason:"tidying", confirm:true}` on
"Save changes to gearbox?" → `success:true`, `desktop_ui_window {action:"close"}` dispatched.
The impact is the largest this bridge can cause. By its own live evidence from 2026-09-02,
closing a modal SOLIDWORKS is blocked on takes the whole application down — and this is the
modal that stands between the user and every unsaved document they have. It was also
DISCOVERABLE: `swx_window_info` reported that dialog `closable:true`, the same shape as round
7's `mainHwnd`.
The reasoning I used to justify removing the bridge-caused-dialog allowlist in round 7 —
*"a verb that can do that should not act on confirm+reason, both of which a caller writes for
itself"* — applied verbatim to this verb, and I applied it only to the sibling. Both refusals
are now on both verbs, and `closable` folds in ALL THREE (frame, attribution, never-auto-answer)
so a caller can act on the field rather than re-deriving the rules.
### `is_frame_window` inverted its own fail-safe
`!class.is_empty() && class != DIALOG_CLASS` — a window ab reports with no `className` was
therefore "not a frame", and closable. Proven: a SOLIDWORKS-pid window titled `bracket.SLDPRT`
with no class was closed and dispatched. It is `class != DIALOG_CLASS` now: unknown means
frame, the same rule `kill_is_unsafe` applies to an unanswerable probe.
### Three of round 7's seven fixes introduced their own defect
`swx_open` is the third. Round 7 changed the DEFAULT to read-only and left `readOnly:false`
caller-settable — on the com host's safe list, where `authorize_command` never runs. By the
comment I wrote in the same commit, that takes a write lock on the user's file and
converts-and-dirties an older-version document in memory. `is_mutating` now takes the args, so
`open {readOnly:false}` is authorized on both sides; the host decides ownership on the PATH
against its own record.
That pattern is worth naming: the fixes that introduced defects were all fixes that changed a
DEFAULT while leaving the dangerous mode reachable. Changing a default is not closing a hole.
### `swx_screenshot` was a general desktop capture tool
`titleContains` was forwarded straight to ab, so `{"titleContains":"1Password"}` captured
whatever matched, from a verb catalogued as "screenshot the SOLIDWORKS window". Not a write,
but it is the user's data and it had no test. It captures BY HANDLE now — the one COM reports,
or an hwnd `swx_window_info` attributes to SOLIDWORKS.
### `swx_uninstall` could erase the audit log
It takes no gate (ab calls it on app uninstall) and removed `ledger.jsonl` with the rest of the
scratch subtree — so an AI could erase the record of what it did by asking the bridge to tidy
up. The ledger is now the one artifact `uninstall` leaves behind; removing the folder is the
user's to do.
### Ownership means "open AND ours", on both sides
Writing the `readOnly` test surfaced a disagreement I had introduced: the host's persisted
record kept a path after the document closed, while the server's registry pruned it. So a
closed bridge-created file was ours to the host and not to the server. The host now drops the
entry on `close_doc` too. The rule both sides state: a document this session created and STILL
HOLDS — not a file this bridge once wrote, which is the reading that could drift onto someone
else's file after a rename.
### Mutation results
Twelve targeted mutations, twelve killed, including the four the review found unkilled. The
three that needed new coverage were all cases the stub made unreachable: the main-hwnd shortcut
(every stub frame also had a non-dialog class), the `ownerConfirmed` guard (every stub window
carried the SOLIDWORKS pid, so nothing was ever unattributable), and the host's
`is_mutating("open")` (the server refused first, so the host's veto was never exercised). The
lesson is the same one as round 7: a stub that only ever presents the easy case tests the easy
case.
Also done: the interference `Done()` drop guard is armed BEFORE the three `try_put`s, so a
panic inside a put cannot leak the session; the dead `isFrame` check in `dismiss` is a real one;
`live-smoke`'s `swx_show_drawing` is pinned; and the e2e assertion that accepted
`window_not_attributed` as an alternative to `window_is_application_frame` — and so pinned
nothing — now requires the frame code.
## Build notes — 2026-09-03 (round 9: the first live run)
The suite ran against Drew's real SOLIDWORKS. **22 passed, 4 failed, aborted** — and the four
failures were all one functional bug, in the exact place eight rounds of security review never
looked.
### What held up
Everything the previous eight rounds built. Drew had his own `Part1` open; the suite created
`Part2`, never touched `Part1`, aborted on the first failure, removed every file it wrote, and
left SOLIDWORKS running. `swx_reset_workspace` reported
`skipped: [{title:"Part1", why:"not created by this bridge in this SOLIDWORKS session"}]` —
session-scoped ownership, working on a real machine against a real user's document. The
"documents unchanged" invariant correctly FAILED and named the orphan. The
`host_app_launch_blocked` finding was confirmed live: *"this IS the Connected edition, which
cannot be launched programmatically."*
### The bug: an unsaved document was unaddressable by every verb
`swx_docs` listed `Part2`; `swx_design_info`, `swx_close_doc` and `swx_reset_workspace` all
reported "document not open: Part2" — **including with no target at all**, on the ACTIVE
document.
Root cause: `GetOpenDocumentByName` wants a FULL PATH. An unsaved document has none, so it
cannot be found by title — and since round 3 the server ALWAYS sends a resolved identity, so
even the no-target case went through it instead of falling back to `ActiveDoc`. The whole
create → model → export flow — the thing Drew actually asked for — was dead for new documents.
The host now resolves a TITLE by walking `GetDocuments()` and comparing `GetTitle()`. That
works for saved and unsaved alike and lets the ambiguity rule be enforced at the host rather
than trusting whichever document SOLIDWORKS hands back first. Paths still win where a path
exists.
**My round-3 rebuttal was right for this case, and the round-4 fix over-corrected.** I argued
then that a pathless document's title IS its identity because SOLIDWORKS will not issue the
same untitled name twice in a session. Round 4 found a real title-collision bug in SAVED
documents and I made path the identity everywhere — including where there is no path. Both
halves are now true at once: path first when there is one, title when there is not.
### Why 89 mock tests passed
The mock resolved titles out of its own map, so it could never express the failure. It now
mirrors the real resolution exactly: a PATH matches only a document that actually has one
(`reported_path` is `""` until saved, and the internal key is not a path), and a TITLE is
resolved by walking the open documents. That change alone makes the old behaviour fail 9 tests.
This is the fourth time a mock that modelled the easy case hid a real defect. The rule I should
have been applying: **the mock must model the API's CONSTRAINTS, not just its results.**
### Also
`HostError` carries a branchable `code` now, propagated through the protocol into
`errorCode` — the live failures came back with `errorCode: None` and prose only, and a caller
cannot branch on "document not open: Part2". `document_not_open`, `ambiguous_target` and
`no_target_document` are the codes.
`live-smoke.py`'s cleanup retries a close by the title SOLIDWORKS is actually reporting, and if
a document still cannot be closed it says so in one line, names it, and says plainly that it is
the suite's own unsaved scratch — not the user's. The live run left an orphan `Part2` and said
nothing about it beyond a failed check.
### An honest limit
`com.rs` is `cfg(windows)` and never executes in the mock suite, so no Linux test can kill a
mutation in it — I verified this by mutating the title-walk and watching every test stay green.
Its correctness rests on the cross-compile, on the mock mirroring its logic, and on the live
run. That is the gap the live run exists to close, and it is why the run found this and the
suite did not.
## Build notes — 2026-09-03 (round 10: live run 2)
**120 passed, 9 failed, no abort** — up from 22-and-aborted. The create → model → export flow
ran end to end on real hardware, which makes report-bugs #1 and #2 PROVEN FIXED rather than
diagnosed: eight formats exported with `saveErrorCode` clean, `documentRenamed:false`, the title
unchanged and real bytes on disk. The `Silent|Copy` path has now actually executed.
### The bug: one letter of case
`swx_save_as` was asked for `…\smoke.sldprt`. SOLIDWORKS reported the document at
`…\smoke.SLDPRT`. The ownership record kept the path we ASKED for; `prune_scratch` compared it
against the path SOLIDWORKS REPORTED; the exact string compare failed; the bridge disowned the
scratch document it had just created and then refused to close or clean it up. Four of the nine
failures cascaded from that, and an orphan was left on the machine.
Two fixes, because either alone would leave the class open:
- **`same_path`** compares Windows paths the way Windows does — case-insensitively, with
separators normalised. Used by `owns_doc`, `prune_scratch`, `by_path`, and both backends'
`host_owns`.
- **The record stores the path SOLIDWORKS REPORTS**, not the one we asked for.
And the principle the reviewer named, which is the actual lesson: **ownership follows the
DOCUMENT, not the string that used to name it.** A non-copy `save_as` retitles and re-paths in
place, so the host moves its claim with it; `remember_created` now evicts by IDENTITY rather
than by title, which is what let an imported document displace the record of a saved one that
happened to share its name.
This is the mirror image of round 9's bug. Both came from identity being DERIVED at each use
instead of TRACKED through the document's life.
### Three mock-fidelity gaps, all of the same family
The mock could not express any of this, which is why 89 tests passed:
1. It did not **uppercase the extension** on save, so requested and reported paths always
matched exactly.
2. It titled an **imported** document by bare stem, not `<stem>.SLDPRT`, so it could not produce
the title collision the live run hit.
3. Its `open` **duplicated** an already-open document instead of returning it, an artifact that
masked the ownership defect in the one test that came closest.
Fixing (1) alone broke three tests immediately — those were the tests that should have caught
the live bug. Fourth round running, the same lesson: **the mock must model the API's behaviour
and constraints, not just its happy-path results.**
### `ambiguous_target` was correct, and the situation was the bridge's fault
`swx_import` of the STEP the suite had just exported produced a second document with the same
title, so close-by-title refused — right answer, self-inflicted problem. Close-by-PATH now
resolves before the title-ambiguity check can fire (a path identifies exactly one document),
and `live-smoke` imports from a different stem so it stops manufacturing the collision.
### "ab is unreachable" must not read as "there are no windows"
Three failures were `ADOM_DIRECT_API_URL not in env` — the dev bridge was launched by hand, so
it never inherited ab's direct-API environment. `swx_window_info` returned `[]`, which is
indistinguishable from a real absence and made a launch artifact look like three bridge bugs.
It now returns `windows: null` with `abAvailable:false` and a hint naming the cause; `swx_status`
reports `abAvailable` up front; and `live-smoke` SKIPS the ab-dependent checks with a clear note
rather than failing them.
## Build notes — 2026-09-03 (round 11: live run 3)
**125 passed, 5 failed** (22-and-aborted → 120/9 → 125/5). The ownership fix held: `save_as`
retitles and the document stays ours, so everything the disown-on-save cascade had blocked now
runs — `CreateCircleByRadius`, `FeatureCut4` and `SetUserPreferenceInteger`, all three previously
shipped-but-never-executed, plus complete file cleanup.
### The bug: `swx_import` registered ownership on the server but not on the host
Three of the five failures were this one cause. `cmd_import` has THREE success paths — LoadFile4
returning a dispatch, a handle recovered from ActiveDoc, and the LoadFile2 fallback — and only
the first called `remember_created`. The live machine took one of the others, so the host's
independent veto refused to close the document the bridge had just imported.
Same shape as run 2's bug, one layer down: an ownership record that does not follow the document
through an operation the bridge itself performed.
**The fix is structural, per the reviewer's ask.** Registration is no longer a per-verb call. The
dispatch loop reads the `{created}` / `{imported}` document out of any successful result and
registers it via `Backend::register_created`, so no branch of no command can forget — and a verb
added tomorrow is registered by existing into the same result shape. `opened` is deliberately NOT
in `CREATED_KEYS`: opening the USER'S file does not make it ours, and a mutation adding it fails
two tests.
That is the third time a defect has come from identity being handled per-site rather than in one
place. The pattern across runs 1-3 is worth stating plainly: **all three live defects were
ownership records failing to follow a document through an operation the bridge performed**, and
none was a safety failure — the deny-by-default posture held throughout, and in every case the
bridge failed CLOSED, refusing to touch a document rather than touching the wrong one.
### A test-isolation defect found while fixing it
Two raw-stdio host tests spawned a com host with no `SWX_SCRATCH_DIR`, so they used the
developer's real scratch root — and the com host PERSISTS its created-document record. They
therefore inherited whatever had run before them, and passed or failed by accident; a mutation
run was what exposed it. Both have isolated scratch dirs now, and the suite is verified
repeatable across consecutive runs.
### The ab surface is now honestly reported as untested
The last two failures were deployment, not the bridge: a hand-launched exe never inherits
`ADOM_DIRECT_API_URL`. The preflight probe and `swx_dismiss_blocking_dialogs` now SKIP with a
note naming the cause, so a hand-launched bridge scores a clean sheet and the ab-delegated
surface reads as UNTESTED rather than broken — which is what it is.
### Re-grading
Nineteen verbs move to ✅ live-proven on run-3 evidence. Two that the evidence does NOT support
were held back: `fusion_close_window` (its ab path was SKIPPED, not exercised) and `fusion_state`
(it ran with `screenshot:false`, so the render-me half is still untested). Overclaiming those
would be exactly the failure this report exists to avoid.
## Build notes — 2026-09-03 (round 12: the launch answer, and an escape hatch nobody could open)
Three defects and one capability. Version **0.3.1** — a new verb and a new capability
change the surface, so lockstep moved.
### The gate was closed in the wrong direction
Drew authorized closing every leftover document from three live runs, including his own
blank `Part1`. The bridge was restarted with `SWX_ALLOW_USER_WRITES=1`, every call
carried `allowUserWrites` + `confirm` + `reason`, and it closed **3 of 5**:
- `swx_close_doc` → *"the com host refuses to close_doc: this document was not created
by this bridge"*
- `swx_close_all_documents {closeUnsaved:true}` → skipped the dirty foreign documents
unconditionally
- `swx_stop {force:true}` → `success:true, stopped:false`, declined silently
Twelve rounds have been spent making sure this bridge cannot write to somebody's data by
accident. This round is the other half of that, and it took three live failures to see
it: **an escape hatch nobody can open is not a safety property, it is a bug.** Every one
of these had the same cause — the server AUTHORIZED the action and then failed to tell
the host, or told it something weaker than what it had checked itself.
`swx_close_doc` built a `Grant` only on the `save:true` branch, so `close_doc
{save:false}` on a foreign document reached the com host carrying no evidence of the
permission at all; the host, holding its own independent veto, correctly refused. And the
host's veto re-checked `allowUserWrites` **alone** while the server checked all three
elements — so "two independent vetoes" was not two applications of one test, it was a
strong test and a weak one that happened to disagree in the direction that blocked the
user. Both sides now call `writes::missing_opt_in`, one function, and a `Grant` carries
the whole chain to the host so it has the same evidence to re-check.
`swx_stop` was the same class of dishonesty pointed the other way: the host does the
right thing (it re-attaches after `ExitApp` and reports that SOLIDWORKS is still there)
and the server dressed that up as a success. It is now `host_app_stop_declined`, with the
host's own reason preserved and a hint that points at the unanswered *"Save changes?"*
modal — explicitly **not** at `swx_kill`, which would discard exactly the work the dialog
is asking about.
### Two more found while proving the fixes
**A refused bulk close was closing things.** `swx_close_all_documents` ran its discard
loop and only then asked whether it was allowed to end the session — so in the default
posture it closed the bridge's own dirty documents, discarding their contents, and
returned a refusal. Side effects on the way to "no". Authorization moved to the top,
where it also supplies the grant the per-document closes need; the reordering is now
structurally enforced, because a mutation that moves it back **does not compile**.
**A document SOLIDWORKS will not answer for was being called clean.** The dirty flag was
read with `unwrap_or(false)`, so a document whose state could not be established never
entered the discard loop, `CloseAllDocuments(false)` then left it alone because it did in
fact need saving, and it came back in the host's `remaining` — left open by a call told to
close everything, with nothing anywhere saying why. That is one of the two documents live
run 3's cleanup could not shift. Same rule as everywhere else now: an unanswerable
question is not permission. Every document still open when the verb returns is named in
`skipped` with a reason, reconciled from the host's own after-the-fact view rather than
from the server's expectations.
The mock could not report unknown dirtiness at all, which is why that guard had no test —
the fourth time this round-by-round pattern has repeated: **the mock modelled the easy
case and hid a real defect.**
### THE LAUNCH ANSWER
For two rounds this bridge reported that SOLIDWORKS Connected "cannot be launched
programmatically", on live evidence: `CoCreateInstance` spawns a process that puts up
*"SOLIDWORKS Design must be launched from the 3DEXPERIENCE Platform"* and never attaches.
The evidence was real and the conclusion was wrong, because it was drawn from the wrong
experiment. The Start-Menu shortcut the Platform installs does not run `sldworks.exe`:
```
CATSTART.exe -run "SWXDesktopLauncher.exe"
-object "--AppName=\"SWXCSWK_AP\" -tenant=<TENANT> -monoapp
-3DRegistryURL=https://<region>-makers-registry.3dexperience.3ds.com"
-nowindow
```
The refusal was **ours**, for bypassing the launcher chain. `swx_start` now invokes that
shortcut — deliberately the `.lnk` itself and never a hand-assembled CATSTART command
line, because the shortcut is the sanctioned artefact and carries that user's own tenant,
whereas a command line we built would be a guess at it.
Design notes worth keeping: the shortcut is chosen by a **ranked** pure function, not
"first `.lnk` found", because a Start-Menu group holds an uninstaller and a pile of side
tools next to the application, and no shortcut at all resolves to `Blocked` (which asks
the user) rather than to a guess. The route decision does not require `installed`, since a
shortcut IS evidence of an install and the exe globs miss the Connected layouts. And the
attach poll is **bounded** — a cold Connected start runs 1-3 minutes and the com host is
the server's only channel, so holding it would freeze `swx_progress` and every other verb;
the first version waited 45 seconds and did exactly that to its own test suite. It answers
`started:true, stillLaunching:true`, never a failure, because a failure invites a second
launch and that means two sign-in windows on somebody's desktop.
`host_app_launch_blocked` now means one thing only: **no shortcut could be found.**
### `swx_run_macro` — scaffolding, labelled as such
`ISldWorks::RunMacro2`, written from the documented signature. NOT run live; the response
says `provenLive:false` and the report grades it accordingly, because that line is the only
thing this report is for.
Its gate is the interesting part. Every other write here is bounded by two questions — is
the document ours, is the destination clear — and a macro answers neither: it is a VBA
project with the whole API in scope and the filesystem behind it. So `Kind::RunMacro` is
**always gated with no carve-out**, and gated *first*, before even the existence check, so
an unauthorized caller does not learn what is on somebody's disk. What is being authorized
is arbitrary code execution on a person's desktop, and the only honest gate for that is the
human saying so each time.
The bundled `resources/macros/adom-hello.swp` is a placeholder that says so in its own
comments. The real template will be **recorded** from SOLIDWORKS' macro recorder — the only
way to be sure of the module and procedure names it expects — and notably the obvious
hello-world, `SendMsgToUser2`, raises a modal, which this bridge must never do.
### Verification
61 verbs · 0.3.1 in lockstep · lib 74 · e2e 28 · protocol 11 (+1 ignored) · 0 failed ·
zero warnings on both targets · 3 consecutive clean runs · **14 mutations, 14 killed**
(one of them uncompilable, which is stronger than a test).
`tools/live-smoke.py` no longer aborts when the write opt-in is on — it could not
otherwise test the paths this round fixed — and every posture-sensitive check is explicitly
two-sided. It proves the launch route was FOUND but never fires it, because firing the
launcher while SOLIDWORKS is up is the one thing that must not happen; and there is no
posture in which it executes a macro.