main
Drew Owens 0.3.3 source + crate build: BOM exported from the SOLIDWORKS assembly, build log §1–11, release script 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 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"
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 — " 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):

# 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 -Forced 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_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_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.