SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
5d02ca9
20d ago
The STEP-import document leak (found 2026-09-08)
Symptom
swx_status reported 1342 open documents; GetDocumentCount() (authoritative COM) confirmed 1342;
swx_docs enumeration timed out at 30s (that many documents cannot be marshalled in the budget);
SOLIDWORKS private memory sat at 8.1 GB on an otherwise idle, responding session.
Cause (corrected after reading cmd_import)
swx_import is NOT itself leaking: it correctly leaves ONE open, unsaved, bridge-created document, the
same contract as swx_new_document. The 1342 are the same two fixtures imported over and over and
never closed — note the -1/-2 title suffixes SOLIDWORKS adds when a prior import of the same name
is still open. Source fixtures First.step/Second.step are not in the current tree, so this is an
earlier test/run loop that imported without closing. Every open document was a STEP-import temporary:
title: First.step.SLDPRT Second.step.SLDPRT First.step-1.SLDPRT ...
path : C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT
swx<pid>\IC~~\ is the folder SOLIDWORKS writes when converting a STEP. These docs are unsaved (temp
path = never saved to a real location). They accumulated because callers imported without closing AND —
the actual bridge defect — before the 0.4.4 ownership fix a host respawn disowned them (bug #12), so
nothing could drain them in the default posture even if it tried. The count is static and no loop is
running, so it is historical, not active, and never drains except by restarting SOLIDWORKS.
Why it matters
- This is the real root of the crate-build memory exhaustion (0.5 GB → 13 GB across the build, then a hang at 17.4 GB / "52 docs"). The 7 fastener imports plus repeated import-based tests each leaked a loaded document; "openDocs" was never really the crate assemblies alone.
- It quietly defeats the quit ladder's precondition ("verified Some(0) documents") and makes
swx_docstime out, which earlier read as the silent-empty bug. - No MCP server in the ecosystem manages this; getting resource lifecycle right is exactly the robustness edge Drew asked for.
The real bridge defects (0.5.0), in order of impact
swx_docs/ count enumeration must degrade, never time out. ReadGetDocumentCountfirst; above a cap, page the enumeration or return{count, truncated:true, sample:[...], _hint:"N documents open; enumeration exceeds budget — likely undrained import temps, run swx_close_import_temps"}instead of blocking 30s and killing the com host.swx_docsmust not time out on a large count: readGetDocumentCountfirst; above a cap, page the enumeration or return{count, truncated:true, sample:[...], _hint:"N documents open; enumeration exceeds budget — likely leaked import temps, see swx_purge_imports"}rather than killing the com host.- A drain verb.
swx_close_import_temps(gated, bridge-owned only): close every open document whose path is under%TEMP%\swx<pid>\IC~~\, by title/path, reporting count freed and memory reclaimed. These are unambiguously transient and bridge-created; never a user document. - Add these
IC~~temps to the ownership model so the default posture can close them (0.4.4 already makes bridge-created docs survivable across respawns; confirm import temps are tracked as created). - Import net-count assertion.
swx_importshould verifyGetDocumentCountrose by exactly 1 and report the resulting doc's title/path, so a caller can close it — and should carry an_hintreminding that an imported doc stays open until closed or saved, like new_document. - NOT a change to swx_import's core contract — it correctly returns one open unsaved doc.
Cleanup of the current 1342
They clear on the next SOLIDWORKS restart (which the launch gate needs anyway). No user document is
among them (all are %TEMP%\swx107688\IC~~\), but closing 1342 live is slow and the enumeration times
out, so a restart is the clean path.
Shipped in 0.5.0 (Workstream A)
All four defects are closed; swx_import's contract is unchanged (it still
returns one open, unsaved, bridge-created document, like swx_new_document).
- Enumeration degrades, never wedges.
swx_docsandapp_statereadGetDocumentCountfirst (one COM call, always answers). Abovehost::doc_enum_cap()(200;SWX_DOC_ENUM_CAPoverrides it for tests) they return{count, truncated:true, sample:[first 10 titles/paths], importTempsInSample, _hint:"…run swx_close_import_temps"}instead of marshalling 1342 handles for 30s and getting the com host killed.app_statealso setsdocumentsTruncated, and the server's ownership prune is SKIPPED when truncated (prune_unless_truncated) — an empty "unlistable" set must not disown every document the bridge created (bug #5's shape, reached through the leak). swx_close_import_temps(new, gated with areason, NO write opt-in): closes every open document whose path matchesis_import_temp—…\Temp\swx<pid>\IC~~\…. The path is the ownership proof and is stronger than the registry: it survives the pid change that made the 1342 (from a dead bridge process) un-ownable, and no user document can match it. Drains in bounded batches (close_import_temps {max}closes a batch and reportsdone; the verb loops to a wall budget), so neither a single com call nor the verb runs unbounded on a huge count.- Import net-count assertion.
cmd_importcounts viaGetDocumentCount(not the enumeration, which would itself time out at 1342), asserts the count rose by exactly one, and returnsdocumentsOpened/netCountOk/documentCountplus a hint that the document stays open until closed or saved. - Ecosystem row 43 — "import / resource lifecycle: no leaked temps, drainable, enumeration never wedges." We are alone.
Tests: tests/import_lifecycle.rs (5) — net-count assertion, degrade-above-cap
(swx_docs stays fast + truncated + sampled), drain in the default posture
(never touches a new_document), bounded batching (done:false then finish),
and drain-after-a-com-host-respawn (the temps are identified by path, not by a
registry the respawn cleared). Plus coverage in the e2e verb walk.
The live 1342 still clear only on the next SOLIDWORKS restart (the launch gate
needs one anyway); swx_close_import_temps is what prevents the next pile.
Adversary round on 0.5.0 → shipped in 0.5.1
Four findings, one of them data loss in the DEFAULT posture.
F1 (HIGH — data loss). is_import_temp proved the wrong thing. It asked only
that \temp\swx and \ic~~\ each appear SOMEWHERE in the path, in any order, in
any folders. So a user's own saved file qualified — D:\Temp\swx-archive\2026\IC~~\customer-final.sldprt,
…\Temp\swxnotes\IC~~\keep.sldprt, or anything with an IC~~ folder ABOVE a
Temp\swx… one — and cmd_close_import_temps closes what qualifies with
CloseDoc, which does not prompt.
It is now a SEGMENT WALK requiring the launcher's actual contiguous layout: a
Temp segment, then immediately a swx<digits> segment (the pid; the 8.3
short form SWX107~1 counts), then an IC~~ segment BELOW it, then a file.
Case-insensitive, either slash. The pid VALUE is deliberately not checked — the
1342 leaked temps belonged to a dead bridge process, and scoping the drain to
"documents open in the session we are attached to" is what makes it useful.
F2 (MEDIUM). An unreadable count fell through to the full enumeration —
count.map(..).unwrap_or(false) — which is the 30s wedge the cap exists to
prevent, arriving exactly when the session is under the memory pressure that made
the count unreadable. None is now UNKNOWN-and-truncated: {count:null, truncated:true} with a hint saying null is not zero, and no marshal is attempted.
app_state the same, so ownership is not pruned against it either. And a verb
that cannot resolve its target now refuses with document_list_unavailable
instead of the confident wrong "no open document is titled X".
F3 (MEDIUM). The mock reported path:"" for a temp while the real
GetPathName returns the IC~~ path, and the mock drain matched a FLAG while the
real one matched the PATH — so is_import_temp, the write gate's exists-check,
save_as and path-keyed ownership were never exercised on a realistic temp path.
F1's subject had no e2e test. The mock now reports the synthetic temp path
everywhere GetPathName would (doc_info and reported_path) and its drain
matches through the same is_import_temp.
Making the mock honest immediately exposed three real defects it had been hiding, all of which existed on the live machine:
- the com host's write veto refused
close_import_tempsoutright (its ownership check resolved a target and found it foreign), so the drain could never run in the default posture — the whole point of the verb. It is now an explicit, justified exemption on the host's allowlist, which is why F1's tightening matters: the path proof is the only guard left. swx_importrecorded only the TITLE in the ownership registry while the open document reported a path, so the two never matched and the bridge refused to close the document it had just imported. It now records the path — through a newremember_scratch_at, notrename_scratch, becauserename_scratchfinds its target by title and would have reassigned the recorded path of a different owned document sharing the imported title (SOLIDWORKS titles an imported STEP<stem>.SLDPRT, so importing beside a saved part of that name is ordinary).- the mock's
host_ownsbranched onsaved, so an unsaved-but-pathed temp was looked up as pathless. It now keys on the path SOLIDWORKS would report, which is what the real backend has always done.
F4 (LOW). The ActiveDoc recovery branch can report another concurrent
import's title/path. No behaviour change: it now sets
titleMayBeAnotherConcurrentImport:true and says so in the hint.
Adversary rounds 6 and 7 → the anchor becomes the whole proof (0.5.2, 0.5.3)
Round 6, D1 (HIGH, data loss). The proof was still only about SHAPE: two
tokens in a path, so a user's own saved file under folders they had named
(…\Temp\swx-archive\2026\IC~~\customer-final.sldprt) qualified for a CloseDoc
with no save prompt. 0.5.2 replaced it with a segment walk requiring the
launcher's contiguous layout — <TEMP>\swx<digits>\IC~~\<file>, IC~~ as a
folder and not a file, swx followed by DIGITS (not swxnotes) — and anchored it
to the resolved %TEMP%.
Round 7, D-1 (HIGH, data loss). The anchor had a fallback that undid it:
matches_resolved || canonical // canonical = the parent ends appdata\local\temp
The canonical arm made the anchor advisory. Any path whose tail read
…\AppData\Local\Temp\swx<digits>\IC~~\<file> qualified wherever it lived — a
restored profile backup on another volume (E:\Backups\drew\AppData\Local\Temp\…),
another user's profile, a copy somebody took of a leaked temp they wanted to keep.
And because matches_resolved was unwrap_or(false), when %TEMP% could not be
read the shape test was the ONLY test: the one place in this bridge where an
unreadable precondition guessed instead of refusing, authorising a close with no
save prompt.
0.5.3:
- the resolved
%TEMP%is the whole location test. Thecanonicalarm is deleted. The pid folder's parent must BE the resolved temp directory, segment for segment. - both sides are resolved, not merely compared.
GetPathNamecan hand backC:\Users\DREW~1\AppData\Local\Temp\…for the very directory%TEMP%reports asC:\Users\drew\AppData\Local\Temp, so segments match when they are equal or one is the other's 8.3 short form — by the real rule (six characters of the name with spaces and periods removed, plus~n), soa~1cannot stand in foradversary.GetLongPathNameis deliberately not used: it answers only for a path that still exists on disk, and this rule is asked about documents whose temp file may already be deleted while the document is still open. - no root, no drain.
temp_root() == Nonematches nothing, andswx_close_import_tempsrefuses withtemp_root_unknownrather than reportingclosed:0, done:true— which, on a session holding hundreds of leaked temps, is a lie a caller acts on. - the rule is now testable off Windows.
SWX_TEMP_ROOTnames the resolved root, and in mock modetemp_root()answers with the simulated launcher'sC:\Users\drew\AppData\Local\Tempinstead of this host's/tmp— because the suite runs on Linux, and a location rule that cannot be exercised here is the one that has now twice been the data-loss rule. The fault filemock-temp-root-unknownmodels an unreadable root so the refusal has a test.
Adversary round 8 → the anchor was right, its FAILURE was silent (0.5.4)
F (MEDIUM, wrong report — not wrong action). temp_root() resolved the
BRIDGE process's %TEMP%, and the drain then required the document's path to sit
exactly under it. That is the correct rule, and it closed the data-loss hole. But
the two directories can legitimately differ: the com host inherits Adom Bridge's
environment, and ab may run under a service profile, a redirected TEMP or a
different account from the interactive session SOLIDWORKS lives in — and the 1342
temps this verb exists for were written by a different, dead bridge process,
whose %TEMP% 0.5.3 simply assumed was ours.
On a mismatch the drain matched nothing and answered:
{"success": true, "closed": 0, "done": true,
"_hint": "Every open STEP/IGES import temporary was closed."}
with three of them open. Nothing was wrongly closed — the miss is the safe direction for the CLOSE — but the REPORT was a confident wrong claim about the user's session, and an operator running launch → drain → quit would have read step 2 as finished and been surprised by the quit gate refusing on open documents.
0.5.4 makes the failure loud instead of silent:
tempRootandtempRootsare in the response, so a mismatch is diagnosable from the output on a machine where nobody can attach a debugger.- near misses are counted.
has_import_temp_tailrecognises the launcher's own…\swx<digits>\IC~~\<file>tail wherever it lives; documents that carry it and fail the root test are reported asimportTempsElsewhere: Nwith animportTempsElsewhereSamplepath. - a matched-nothing drain does not claim completion. With
N > 0andclosed == 0the verb reportsdone: falseand a hint that says what happened, why nothing was closed (the resolved temp directory is the whole proof, and the close does not prompt), and what to do: read the sample path and restart the bridge withSWX_TEMP_ROOTset to the directory above theswx<pid>folder, or close them withswx_close_all_safely, which writes every document to a recovery folder and verifies it before closing. Never ask the user to close documents — Drew, 2026-09-11: "I despise every time you ask for help closing." - two candidate roots, with a documented precedence.
SWX_TEMP_ROOT, when set, is the whole set. Otherwise the set is this process's%TEMP%(TEMP,TMP,TMPDIR) and the interactive user'sHKCU\Environment\TEMPexpanded, each included only if readable; a document under either qualifies, and an empty set still refuses withtemp_root_unknown. Reading that registry value is the least invasive way to learn the temp directory of the session SOLIDWORKS is in: its ownGetTempPathcannot be called from another process, and reading its PEB for its environment block is invasive, version-fragile and needs rights this bridge should not want.
One note answered differently from how it was raised. Pass 8 also observed
that accepting drew~1 as the 8.3 form of drew is a widening, since Windows
does not mangle a name that already fits 8.3, and suggested restricting the tilde
form to segments longer than 8 characters or containing a space or period. That is
right about mangling and wrong about the other case Windows generates a tilde
name for: a collision. When two entries in a directory claim the same short
name, the short name of the first becomes the whole name plus ~n — and the
standard example is a profile directory on a domain-joined machine, where
C:\Users\drew sits beside C:\Users\drew.ADOM and the short name of drew
really is DREW~1. Restricting to the mangled form would make the drain MISS real
leaked temps on exactly the machine class this bridge ships to, which is the wrong
direction for a verb that exists because 1342 temps went unclosed. So the rule is
now exact rather than merely tighter: the mangled form requires the
six-character head (ADVERS~1 for adversary, so a~1 is rejected), the
collision form requires the whole name (DREW~1 for drew), and neither admits a
loose prefix — which was the actual looseness worth removing. Pass 7's positive
control stays green because the case it pins is real.
LIVE, 2026-09-09 — the degrade path was itself an unbounded marshal (0.5.5)
0.5.4 was deployed to the dev bridge and swx_docs was run against Drew's real
session: 2144 documents open, and it still timed out at 30 s and the com host
was killed. GetDocumentCount had answered in 0.7 s.
The cap was doing its job and the code under it was not. Above the cap,
cmd_docs did this:
let all = sw.call("GetDocuments", &[])?.as_array(); // EVERY handle, one call
let sample = all.iter().take(DOC_SAMPLE)… // …to look at ten
let temps = all.iter().filter(|d| GetPathName…) // …then 2144 round trips
GetDocuments marshals the whole array in a single call and did not return
within the budget; the temps loop then added one GetPathName per document —
2144 more round trips — to compute a number reported in a field literally named
importTempsInSample. The mock's enumeration was instant, so every test
passed and ecosystem row 37's "degrades honestly instead of timing out" was
mock-proven and false live. The same shape was in cmd_close_import_temps: the
drain would have wedged on the exact session it exists to drain.
0.5.5:
walk_documents—GetFirstDocument()→GetNext(), one document at a time, bounded by a count AND a time box. Measured live the day before on the 1342-document session:$sw.GetFirstDocument().GetNext().GetTitle()answered immediately. Cost is now O(what we look at), never O(what is open).cmd_docsabove the cap walksDOC_SAMPLEdocuments and nothing more.importTempsInSampleis counted over the sample only — as its name always said — and reportsimportTempsInSampleOfso a bare number cannot read as a session count. A walk stopped by its 5 s budget sayssampleTruncatedByTime:true.- The drain walks too, in two phases: collect up to
maxvictims from a bounded window (about 4× the batch, 20 s budget), then close them by title — closing while walking would invalidate the iteration. It reportsdocumentsScannedandwalkTruncatedByTime, soimportTempsElsewhereis readable as a count over the window rather than over the session. documents()refuses above the cap (docs_enumeration_capped) instead of letting any caller discover the wedge the hard way.close_all_documentsgets a capped branch that still performs the close — one COM call — and reports counts withclosed/remaining/wouldPromptas null, never as empty lists, with the server turning that into an explicit UNKNOWN entry inskipped.app_stateandcheck_recoveryalready read the count only.- The mock can now be slow.
SWX_MOCK_DOC_LATENCY_MScharges per document touched, at exactly the points the real backend pays a round trip, andSWX_MOCK_OPEN_DOCSfloods a session. The regression test holds the live shape: 2144 documents, cap 200, 15 ms/document — 32 s of simulated cost if anything marshals the session — and requiresswx_docsto answer in under 3 s withtruncated:true, the drain to close a bounded batch without scanning 2144, andapp_stateto degrade the same way.
The lesson is the one this project keeps relearning, in a new place: a mock that cannot exhibit the failure cannot prove the fix. The cap was tested; the cost of the code under the cap was not, because nothing in the mock had a cost at all.
Adversary round 9 → a title is not an identity, and the drain's CLOSE was the one place the rule was missing (0.5.6)
The rule has been enforced everywhere else in this bridge for four releases:
resolution puts path before docName, swx_docs refuses an ambiguous title
with ambiguous_target, ownership rows are keyed on the path when there is one,
same_path exists for exactly this reason. The drain broke it at the last step:
let mut victims: Vec<(String, String)> = …; // chosen BY PATH
for (title, path) in victims {
if sw.call("CloseDoc", &[&Var::bstr(&title)]).is_ok() // …closed BY TITLE
ISldWorks::CloseDoc resolves a document by name and does not prompt.
SOLIDWORKS titles are not unique across paths, and Drew's live session holds
exactly the collision that matters: leaked conversion temporaries titled
First.step-1.SLDPRT open beside his saved
C:\Users\drew\validation\First.step-1.SLDPRT. Asking by that title lets
SOLIDWORKS choose which one dies — and the response would have recorded the
temp's path as closed either way.
The 254 green tests were not evidence about the close key: the mock's drain removed the document by its own unique internal key, so it always closed exactly the right one. Same failure of the mock as pass 8 (the root always agreed) and pass 5's F3 (the path was always empty), in a new place.
0.5.6:
- the victim is closed by the identity it was chosen by — its full path. For
an import temporary
GetPathNameIS theIC~~path, which is what qualified it in the first place. Never the bare title, here or inclose_doc(which passes the path whenever the document has one;resolvealready refused an ambiguous title before it). - a title collision is refused, not resolved. Every document the walk passes
contributes
(title, path)— the title is read for all of them, not just the victims, which is one extra call per document inside an already bounded window — and any victim whose title another open document shares is skipped and reported astitleCollisions: [{title, paths}]. The hint says what only the user can decide: close it yourself, or save the other document under another name. The scan deliberately does not stop atmax, because stopping it would blind the collision check to a document later in the window. - every close is verified by path.
GetOpenDocumentByName(path)is one call: after a close that reported success, the document must be gone. If it is still open then something else was closed, and the drain stops immediately and reportswrong_document_closedas an error with everything measured. That branch must be unreachable; the check is the proof rather than the hope. - the mock now models
CloseDoc's by-name resolution — a full path matches only itself, a title resolves to the FIRST open document carrying it — soa_saved_document_sharing_a_temps_title_survives_the_drainis a real test instead of a pin, and a future close-by-title would fail the suite. - a walk that ended early is not reported as complete.
walk_documentsnow returns WHY it stopped (reachedEnd/hitMax/timedOut/iterationFailed), because aGetNextthat fails on an invalidated iteration — a document closed under us — is not the end of the session.walkCompleteandwalkStoppedare in the response anddonerequiresreachedEnd, no collisions and no failed closes.
Adversary round 10 → the close key is right; the WINDOW it reasons over was not (0.5.7)
Pass 10 confirmed the pass-9 fix at the mechanism (the fixture scenario — two temps plus a saved same-titled document, bridge-launched — is safe) and found that everything around the close still spoke as though it had seen the session.
donedid not consultWalkStop::complete().hit_limitwas seeded fromwalk_stop == TimedOutalone, so a walk that stopped at its scan cap (hitMax) or on an invalidated iteration (iterationFailed) was treated as one that reached the end. Measured on the adversary's fixture: 55 of 60 documents scanned, a temporary sitting at #60 still open,done:true. It is nowdone = reachedEnd && scanned == documentCountBefore && no collisions && no failed closes && the batch limit was not hit && no root mismatch.walkStopis published (reachedEnd/hitMax/timedOut/iterationFailed). It was computed, named, and never sent — so a caller could not tell those four apart, andtitleCollisions: []was uninterpretable.- The collision claim is scoped to the window it was actually checked over.
titleCollisionsCheckedOver: documentsScanned, and the hint says so in words: collisions were checked among the N documents this call scanned, not across the session. A same-titled document beyond the window is invisible to the check — harmless for the close, because the path is the key, and load-bearing for the report. - The mock is window-bound now. It selected victims and checked collisions over the WHOLE session while reporting a 55-document scan, so it closed documents its own reported window never reached — which is why 257 green tests said nothing about any of the above. Victim selection, the collision check and the near-miss count all run over the walked window only.
- The
CloseDoc(path)premise is now discriminated, not assumed. The help text says only "name of the document to close"; that a full path is accepted has never been run live. If a close returns success and the document is still open, one extra count settles which of two very different things happened:- the count did not move → the call did nothing. That is a CAPABILITY
failure:
closeIgnoredPath:true,errorCode:"close_by_path_unsupported", the drain stops, and it does not fall back to a title — that is exactly the ambiguity round nine removed. An unreadable count reads as this case, because it is the one that accuses nobody. - the count dropped while our victim is still open → some other document
was closed:
wrong_document_closed, stop, report loudly. Still unreachable; still checked. The mock models the failing premise (mock-closedoc-ignores-path: the path form returns success and closes nothing), so the discriminator has a test.
- the count did not move → the call did nothing. That is a CAPABILITY
failure:
- The server forwards
closedDocs(title + path for every document closed) andtitleCollisions, not just counts. A caller needs to know WHICH files went.
tools/drain-gate.py is the live proof of the premise, on documents the bridge
makes itself: import the same fixture twice, save one copy under the same title
into a scratch dir, then ONE swx_close_import_temps {batch:1} and assert that
exactly one document closed, that it was named by its IC~~ path, that the saved
twin is still open, that the count dropped by exactly one, that the collision was
reported, and that every document open at baseline is still open. It closes only
what it created and removes its own scratch directory.
Adversary round 11 → the discriminator's third case, and the gate's own honesty (0.5.8)
The unreadable count took the benign arm. When CloseDoc(path) returned
success and the document was still open, 0.5.7 asked whether the open-document
count had moved — and an unreadable count fell into _ => true, meaning "the call
did nothing": close_by_path_unsupported, a capability problem, nobody accused.
But a count can become unreadable because something large just changed.
Closing a big assembly is exactly the kind of event that makes
GetDocumentCount stop answering, so the reassuring reading is the one this
bridge may not take. There are three outcomes, not two:
| after a close that "succeeded" while the victim is still open | verdict |
|---|---|
| the count did not move | the call did nothing — close_by_path_unsupported |
| the count DROPPED | another document was closed — wrong_document_closed |
| the count cannot be read | close_outcome_unknown, outcome:"undetermined" |
The third stops the drain and says what it does not know: the victim's path, the count immediately before the close, and "a human must check whether this path, or another document, closed". It never claims nothing happened.
And it had no fixture. The mock read its count from self.docs.len(), so
mock-doccount-none could not reach the drain and that arm was dead code as far
as the suite was concerned — the arm that decides whether a user's document was
closed. The mock now reads every count through one seam that honours the fault.
The gate's own claims got the same treatment (tools/drain-gate.py):
- the PASS banner is conditional. With the saved twin never open it prints PARTIAL — the close key is proven, the collision half is untested — instead of a pass that covers something it did not exercise.
- step 3 now tries the twin save in the default posture first, with no
allowUserWrites, and recordsdefaultGateAllowedScratchSaveas a finding. A bridge-created document written to a non-existing path inside a scratch dir the gate made is exactly what the choke-point rule is supposed to allow, so a refusal there is a BRIDGE BUG the gate names rather than works around. It then retries with the opt-in so the rest of the run still happens; the finding stands either way. (It does not reproduce in the mock — the rule is now pinned bythe_default_posture_lets_the_bridge_save_its_own_document_to_a_new_path, which also pins that overwriting an EXISTING file still refuses.) - cleanup deletes only the two files the gate wrote, by name, and removes the directory only if it is then empty. A recursive delete of a directory whose contents we did not write is the same class of act this bridge refuses everywhere else — and SOLIDWORKS puts its own artefacts in there.
Adversary round 12 → the write gate's own unanswerable question (0.5.9)
Not about the drain at all: about the choke point every write in this bridge goes through. The destination test was
let clobbers = destination.map(|d| !d.is_empty() && Path::new(d).exists());
and Path::exists() returns false for two different facts: nothing is there,
and I could not look — a parent directory this process cannot traverse, a UNC
share it has no token for, a device that is not ready, a path over the length
limit. The gate read the second as "the destination is free" and allowed the
write with no opt-in, no confirm and no reason.
It matters because the two processes do not share a filesystem view. The com
host is a child of Adom Bridge; SOLIDWORKS runs in the user's interactive session
with the user's network token. \\server\share\part.sldprt that the bridge cannot
stat is a file SOLIDWORKS can open and overwrite — with the gate having already
said yes. This was the last place in the bridge where an unanswerable question
read as permission.
0.5.9 gives the question three answers, in one shared function
(writes::destination_state) used by both enforcement layers:
symlink_metadata |
verdict |
|---|---|
Ok(_) |
Occupied — overwriting needs the full opt-in |
Err(NotFound) |
Free — a bridge-created document may be written there with no opt-in |
any other Err |
Unverifiable — REFUSE, destination_unverifiable |
symlink_metadata rather than try_exists deliberately: a dangling symlink IS
something at that path, and writing through it lands somewhere the caller did not
name. The refusal happens before the opt-in is consulted, and the hint says why:
the opt-in answers "may I overwrite this?", and this is "I do not know what is
there" — so the opt-in cannot waive it. Both layers are tested: the server's via
swx_save_as, and the com host's by driving its stdio protocol directly with
the opt-in fully granted, so nothing but its own veto can be what refuses.
And the sweep item, which is the same defect one layer down. The server read
closeOutcomeUnknown with unwrap_or(false) — so a com host too old to send that
field reported "the outcome was determined; nobody needs to look". The com host
and the server are separate binaries and can be a mismatched pair (a half-applied
update, a stale exe in a cache dir). Now: the ready handshake carries the host's
version and a skew is an explicit boot error naming both versions, and the field
itself is read as Option<bool> where absent beside a reported close failure is
treated as undetermined.
# The STEP-import document leak (found 2026-09-08)
## Symptom
`swx_status` reported **1342 open documents**; `GetDocumentCount()` (authoritative COM) confirmed 1342;
`swx_docs` enumeration timed out at 30s (that many documents cannot be marshalled in the budget);
SOLIDWORKS private memory sat at **8.1 GB** on an otherwise idle, responding session.
## Cause (corrected after reading cmd_import)
`swx_import` is NOT itself leaking: it correctly leaves ONE open, unsaved, bridge-created document, the
same contract as `swx_new_document`. The 1342 are the **same two fixtures imported over and over and
never closed** — note the `-1`/`-2` title suffixes SOLIDWORKS adds when a prior import of the same name
is still open. Source fixtures `First.step`/`Second.step` are not in the current tree, so this is an
earlier test/run loop that imported without closing. Every open document was a STEP-import temporary:
```
title: First.step.SLDPRT Second.step.SLDPRT First.step-1.SLDPRT ...
path : C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT
```
`swx<pid>\IC~~\` is the folder SOLIDWORKS writes when converting a STEP. These docs are unsaved (temp
path = never saved to a real location). They accumulated because callers imported without closing AND —
the actual bridge defect — **before the 0.4.4 ownership fix a host respawn disowned them (bug #12), so
nothing could drain them in the default posture even if it tried.** The count is static and no loop is
running, so it is historical, not active, and never drains except by restarting SOLIDWORKS.
## Why it matters
- **This is the real root of the crate-build memory exhaustion** (0.5 GB → 13 GB across the build, then a
hang at 17.4 GB / "52 docs"). The 7 fastener imports plus repeated import-based tests each leaked a
loaded document; "openDocs" was never really the crate assemblies alone.
- It quietly defeats the quit ladder's precondition ("verified Some(0) documents") and makes
`swx_docs` time out, which earlier read as the silent-empty bug.
- No MCP server in the ecosystem manages this; getting resource lifecycle right is exactly the
robustness edge Drew asked for.
## The real bridge defects (0.5.0), in order of impact
1. **`swx_docs` / count enumeration must degrade, never time out.** Read `GetDocumentCount` first;
above a cap, page the enumeration or return `{count, truncated:true, sample:[...],
_hint:"N documents open; enumeration exceeds budget — likely undrained import temps, run swx_close_import_temps"}`
instead of blocking 30s and killing the com host. `swx_docs` must not time out on a large count: read `GetDocumentCount`
first; above a cap, page the enumeration or return `{count, truncated:true, sample:[...],
_hint:"N documents open; enumeration exceeds budget — likely leaked import temps, see swx_purge_imports"}`
rather than killing the com host.
2. **A drain verb.** `swx_close_import_temps` (gated, bridge-owned only): close every open document whose
path is under `%TEMP%\swx<pid>\IC~~\`, by title/path, reporting count freed and memory reclaimed.
These are unambiguously transient and bridge-created; never a user document.
3. Add these `IC~~` temps to the ownership model so the default posture can close them (0.4.4 already
makes bridge-created docs survivable across respawns; confirm import temps are tracked as created).
4. **Import net-count assertion.** `swx_import` should verify `GetDocumentCount` rose by exactly 1 and
report the resulting doc's title/path, so a caller can close it — and should carry an `_hint`
reminding that an imported doc stays open until closed or saved, like new_document.
5. NOT a change to swx_import's core contract — it correctly returns one open unsaved doc.
## Cleanup of the current 1342
They clear on the next SOLIDWORKS restart (which the launch gate needs anyway). No user document is
among them (all are `%TEMP%\swx107688\IC~~\`), but closing 1342 live is slow and the enumeration times
out, so a restart is the clean path.
## Shipped in 0.5.0 (Workstream A)
All four defects are closed; `swx_import`'s contract is unchanged (it still
returns one open, unsaved, bridge-created document, like `swx_new_document`).
1. **Enumeration degrades, never wedges.** `swx_docs` and `app_state` read
`GetDocumentCount` first (one COM call, always answers). Above
`host::doc_enum_cap()` (200; `SWX_DOC_ENUM_CAP` overrides it for tests) they
return `{count, truncated:true, sample:[first 10 titles/paths],
importTempsInSample, _hint:"…run swx_close_import_temps"}` instead of
marshalling 1342 handles for 30s and getting the com host killed. `app_state`
also sets `documentsTruncated`, and the server's ownership prune is SKIPPED
when truncated (`prune_unless_truncated`) — an empty "unlistable" set must not
disown every document the bridge created (bug #5's shape, reached through the
leak).
2. **`swx_close_import_temps`** (new, gated with a `reason`, NO write opt-in):
closes every open document whose path matches `is_import_temp` —
`…\Temp\swx<pid>\IC~~\…`. The path is the ownership proof and is stronger
than the registry: it survives the pid change that made the 1342 (from a dead
bridge process) un-ownable, and no user document can match it. Drains in
bounded batches (`close_import_temps {max}` closes a batch and reports `done`;
the verb loops to a wall budget), so neither a single com call nor the verb
runs unbounded on a huge count.
3. **Import net-count assertion.** `cmd_import` counts via `GetDocumentCount`
(not the enumeration, which would itself time out at 1342), asserts the count
rose by exactly one, and returns `documentsOpened` / `netCountOk` /
`documentCount` plus a hint that the document stays open until closed or
saved.
4. **Ecosystem row 43** — "import / resource lifecycle: no leaked temps,
drainable, enumeration never wedges." We are alone.
Tests: `tests/import_lifecycle.rs` (5) — net-count assertion, degrade-above-cap
(swx_docs stays fast + truncated + sampled), drain in the default posture
(never touches a `new_document`), bounded batching (`done:false` then finish),
and drain-after-a-com-host-respawn (the temps are identified by path, not by a
registry the respawn cleared). Plus coverage in the e2e verb walk.
The live 1342 still clear only on the next SOLIDWORKS restart (the launch gate
needs one anyway); `swx_close_import_temps` is what prevents the next pile.
## Adversary round on 0.5.0 → shipped in 0.5.1
Four findings, one of them data loss in the DEFAULT posture.
**F1 (HIGH — data loss). `is_import_temp` proved the wrong thing.** It asked only
that `\temp\swx` and `\ic~~\` each appear SOMEWHERE in the path, in any order, in
any folders. So a user's own saved file qualified — `D:\Temp\swx-archive\2026\IC~~\customer-final.sldprt`,
`…\Temp\swxnotes\IC~~\keep.sldprt`, or anything with an `IC~~` folder ABOVE a
`Temp\swx…` one — and `cmd_close_import_temps` closes what qualifies with
`CloseDoc`, which does not prompt.
It is now a SEGMENT WALK requiring the launcher's actual contiguous layout: a
`Temp` segment, then **immediately** a `swx<digits>` segment (the pid; the 8.3
short form `SWX107~1` counts), then an `IC~~` segment BELOW it, then a file.
Case-insensitive, either slash. The pid VALUE is deliberately not checked — the
1342 leaked temps belonged to a dead bridge process, and scoping the drain to
"documents open in the session we are attached to" is what makes it useful.
**F2 (MEDIUM). An unreadable count fell through to the full enumeration** —
`count.map(..).unwrap_or(false)` — which is the 30s wedge the cap exists to
prevent, arriving exactly when the session is under the memory pressure that made
the count unreadable. `None` is now UNKNOWN-and-truncated: `{count:null,
truncated:true}` with a hint saying null is not zero, and no marshal is attempted.
`app_state` the same, so ownership is not pruned against it either. And a verb
that cannot resolve its target now refuses with **`document_list_unavailable`**
instead of the confident wrong "no open document is titled X".
**F3 (MEDIUM). The mock reported `path:""` for a temp** while the real
`GetPathName` returns the `IC~~` path, and the mock drain matched a FLAG while the
real one matched the PATH — so `is_import_temp`, the write gate's exists-check,
`save_as` and path-keyed ownership were never exercised on a realistic temp path.
F1's subject had no e2e test. The mock now reports the synthetic temp path
everywhere `GetPathName` would (`doc_info` and `reported_path`) and its drain
matches through the same `is_import_temp`.
Making the mock honest immediately exposed **three real defects it had been
hiding**, all of which existed on the live machine:
* the com host's write veto refused `close_import_temps` outright (its ownership
check resolved a target and found it foreign), so the drain could never run in
the default posture — the whole point of the verb. It is now an explicit,
justified exemption on the host's allowlist, which is *why* F1's tightening
matters: the path proof is the only guard left.
* `swx_import` recorded only the TITLE in the ownership registry while the open
document reported a path, so the two never matched and the bridge refused to
close **the document it had just imported**. It now records the path — through a
new `remember_scratch_at`, not `rename_scratch`, because `rename_scratch` finds
its target by title and would have reassigned the recorded path of a *different*
owned document sharing the imported title (SOLIDWORKS titles an imported STEP
`<stem>.SLDPRT`, so importing beside a saved part of that name is ordinary).
* the mock's `host_owns` branched on `saved`, so an unsaved-but-pathed temp was
looked up as pathless. It now keys on the path SOLIDWORKS would report, which is
what the real backend has always done.
**F4 (LOW). The `ActiveDoc` recovery branch** can report another concurrent
import's title/path. No behaviour change: it now sets
`titleMayBeAnotherConcurrentImport:true` and says so in the hint.
## Adversary rounds 6 and 7 → the anchor becomes the whole proof (0.5.2, 0.5.3)
**Round 6, D1 (HIGH, data loss).** The proof was still only about SHAPE: two
tokens in a path, so a user's own saved file under folders *they* had named
(`…\Temp\swx-archive\2026\IC~~\customer-final.sldprt`) qualified for a `CloseDoc`
with no save prompt. 0.5.2 replaced it with a segment walk requiring the
launcher's contiguous layout — `<TEMP>\swx<digits>\IC~~\<file>`, `IC~~` as a
folder and not a file, `swx` followed by DIGITS (not `swxnotes`) — and anchored it
to the resolved `%TEMP%`.
**Round 7, D-1 (HIGH, data loss).** The anchor had a fallback that undid it:
```rust
matches_resolved || canonical // canonical = the parent ends appdata\local\temp
```
The `canonical` arm made the anchor advisory. Any path whose tail read
`…\AppData\Local\Temp\swx<digits>\IC~~\<file>` qualified **wherever it lived** — a
restored profile backup on another volume (`E:\Backups\drew\AppData\Local\Temp\…`),
another user's profile, a copy somebody took of a leaked temp they wanted to keep.
And because `matches_resolved` was `unwrap_or(false)`, when `%TEMP%` could not be
read the shape test was the ONLY test: the one place in this bridge where an
unreadable precondition guessed instead of refusing, authorising a close with no
save prompt.
0.5.3:
* **the resolved `%TEMP%` is the whole location test.** The `canonical` arm is
deleted. The pid folder's parent must BE the resolved temp directory, segment
for segment.
* **both sides are resolved, not merely compared.** `GetPathName` can hand back
`C:\Users\DREW~1\AppData\Local\Temp\…` for the very directory `%TEMP%` reports
as `C:\Users\drew\AppData\Local\Temp`, so segments match when they are equal
**or** one is the other's 8.3 short form — by the real rule (six characters of
the name with spaces and periods removed, plus `~n`), so `a~1` cannot stand in
for `adversary`. `GetLongPathName` is deliberately not used: it answers only for
a path that still exists on disk, and this rule is asked about documents whose
temp file may already be deleted while the document is still open.
* **no root, no drain.** `temp_root() == None` matches nothing, and
`swx_close_import_temps` refuses with `temp_root_unknown` rather than reporting
`closed:0, done:true` — which, on a session holding hundreds of leaked temps, is
a lie a caller acts on.
* **the rule is now testable off Windows.** `SWX_TEMP_ROOT` names the resolved
root, and in mock mode `temp_root()` answers with the simulated launcher's
`C:\Users\drew\AppData\Local\Temp` instead of this host's `/tmp` — because the
suite runs on Linux, and a location rule that cannot be exercised here is the
one that has now twice been the data-loss rule. The fault file
`mock-temp-root-unknown` models an unreadable root so the refusal has a test.
## Adversary round 8 → the anchor was right, its FAILURE was silent (0.5.4)
**F (MEDIUM, wrong report — not wrong action).** `temp_root()` resolved the
BRIDGE process's `%TEMP%`, and the drain then required the document's path to sit
exactly under it. That is the correct rule, and it closed the data-loss hole. But
the two directories can legitimately differ: the com host inherits Adom Bridge's
environment, and ab may run under a service profile, a redirected `TEMP` or a
different account from the interactive session SOLIDWORKS lives in — and the 1342
temps this verb exists for were written by a **different, dead** bridge process,
whose `%TEMP%` 0.5.3 simply assumed was ours.
On a mismatch the drain matched nothing and answered:
```json
{"success": true, "closed": 0, "done": true,
"_hint": "Every open STEP/IGES import temporary was closed."}
```
with three of them open. Nothing was wrongly closed — the miss is the safe
direction for the CLOSE — but the REPORT was a confident wrong claim about the
user's session, and an operator running launch → drain → quit would have read
step 2 as finished and been surprised by the quit gate refusing on open
documents.
0.5.4 makes the failure loud instead of silent:
* **`tempRoot` and `tempRoots` are in the response**, so a mismatch is
diagnosable from the output on a machine where nobody can attach a debugger.
* **near misses are counted.** `has_import_temp_tail` recognises the launcher's
own `…\swx<digits>\IC~~\<file>` tail *wherever* it lives; documents that carry
it and fail the root test are reported as `importTempsElsewhere: N` with an
`importTempsElsewhereSample` path.
* **a matched-nothing drain does not claim completion.** With `N > 0` and
`closed == 0` the verb reports `done: false` and a hint that says what
happened, why nothing was closed (the resolved temp directory is the whole
proof, and the close does not prompt), and what to do: read the sample path and
restart the bridge with `SWX_TEMP_ROOT` set to the directory above the
`swx<pid>` folder, or close them with `swx_close_all_safely`, which writes every
document to a recovery folder and verifies it before closing. **Never ask the
user to close documents** — Drew, 2026-09-11: *"I despise every time you ask for
help closing."*
* **two candidate roots, with a documented precedence.** `SWX_TEMP_ROOT`, when
set, is the whole set. Otherwise the set is this process's `%TEMP%` (`TEMP`,
`TMP`, `TMPDIR`) **and** the interactive user's `HKCU\Environment\TEMP`
expanded, each included only if readable; a document under **either**
qualifies, and an empty set still refuses with `temp_root_unknown`. Reading that
registry value is the least invasive way to learn the temp directory of the
session SOLIDWORKS is in: its own `GetTempPath` cannot be called from another
process, and reading its PEB for its environment block is invasive,
version-fragile and needs rights this bridge should not want.
**One note answered differently from how it was raised.** Pass 8 also observed
that accepting `drew~1` as the 8.3 form of `drew` is a widening, since Windows
does not mangle a name that already fits 8.3, and suggested restricting the tilde
form to segments longer than 8 characters or containing a space or period. That is
right about *mangling* and wrong about the other case Windows generates a tilde
name for: a **collision**. When two entries in a directory claim the same short
name, the short name of the first becomes the whole name plus `~n` — and the
standard example is a profile directory on a domain-joined machine, where
`C:\Users\drew` sits beside `C:\Users\drew.ADOM` and the short name of `drew`
really is `DREW~1`. Restricting to the mangled form would make the drain MISS real
leaked temps on exactly the machine class this bridge ships to, which is the wrong
direction for a verb that exists because 1342 temps went unclosed. So the rule is
now **exact rather than merely tighter**: the mangled form requires the
six-character head (`ADVERS~1` for `adversary`, so `a~1` is rejected), the
collision form requires the whole name (`DREW~1` for `drew`), and neither admits a
loose prefix — which was the actual looseness worth removing. Pass 7's positive
control stays green because the case it pins is real.
## LIVE, 2026-09-09 — the degrade path was itself an unbounded marshal (0.5.5)
0.5.4 was deployed to the dev bridge and `swx_docs` was run against Drew's real
session: **2144 documents open, and it still timed out at 30 s and the com host
was killed.** `GetDocumentCount` had answered in 0.7 s.
The cap was doing its job and the code under it was not. Above the cap,
`cmd_docs` did this:
```rust
let all = sw.call("GetDocuments", &[])?.as_array(); // EVERY handle, one call
let sample = all.iter().take(DOC_SAMPLE)… // …to look at ten
let temps = all.iter().filter(|d| GetPathName…) // …then 2144 round trips
```
`GetDocuments` marshals the whole array in a single call and did not return
within the budget; the `temps` loop then added one `GetPathName` per document —
2144 more round trips — to compute a number reported in a field literally named
`importTempsInSample`. **The mock's enumeration was instant**, so every test
passed and ecosystem row 37's "degrades honestly instead of timing out" was
mock-proven and false live. The same shape was in `cmd_close_import_temps`: the
drain would have wedged on the exact session it exists to drain.
0.5.5:
* **`walk_documents`** — `GetFirstDocument()` → `GetNext()`, one document at a
time, bounded by a count AND a time box. Measured live the day before on the
1342-document session: `$sw.GetFirstDocument().GetNext().GetTitle()` answered
immediately. Cost is now O(what we look at), never O(what is open).
* **`cmd_docs` above the cap walks `DOC_SAMPLE` documents and nothing more.**
`importTempsInSample` is counted over the sample only — as its name always
said — and reports `importTempsInSampleOf` so a bare number cannot read as a
session count. A walk stopped by its 5 s budget says
`sampleTruncatedByTime:true`.
* **The drain walks too**, in two phases: collect up to `max` victims from a
bounded window (about 4× the batch, 20 s budget), then close them by title —
closing while walking would invalidate the iteration. It reports
`documentsScanned` and `walkTruncatedByTime`, so `importTempsElsewhere` is
readable as a count over the window rather than over the session.
* **`documents()` refuses above the cap** (`docs_enumeration_capped`) instead of
letting any caller discover the wedge the hard way. `close_all_documents` gets
a capped branch that still performs the close — one COM call — and reports
counts with `closed`/`remaining`/`wouldPrompt` as **null**, never as empty
lists, with the server turning that into an explicit UNKNOWN entry in
`skipped`. `app_state` and `check_recovery` already read the count only.
* **The mock can now be slow.** `SWX_MOCK_DOC_LATENCY_MS` charges per document
touched, at exactly the points the real backend pays a round trip, and
`SWX_MOCK_OPEN_DOCS` floods a session. The regression test holds the live
shape: 2144 documents, cap 200, 15 ms/document — 32 s of simulated cost if
anything marshals the session — and requires `swx_docs` to answer in under 3 s
with `truncated:true`, the drain to close a bounded batch without scanning
2144, and `app_state` to degrade the same way.
The lesson is the one this project keeps relearning, in a new place: **a mock
that cannot exhibit the failure cannot prove the fix.** The cap was tested; the
cost of the code under the cap was not, because nothing in the mock had a cost at
all.
## Adversary round 9 → a title is not an identity, and the drain's CLOSE was the one place the rule was missing (0.5.6)
The rule has been enforced everywhere else in this bridge for four releases:
resolution puts `path` before `docName`, `swx_docs` refuses an ambiguous title
with `ambiguous_target`, ownership rows are keyed on the path when there is one,
`same_path` exists for exactly this reason. The drain broke it at the last step:
```rust
let mut victims: Vec<(String, String)> = …; // chosen BY PATH
for (title, path) in victims {
if sw.call("CloseDoc", &[&Var::bstr(&title)]).is_ok() // …closed BY TITLE
```
`ISldWorks::CloseDoc` resolves a document **by name** and does not prompt.
SOLIDWORKS titles are not unique across paths, and Drew's live session holds
exactly the collision that matters: leaked conversion temporaries titled
`First.step-1.SLDPRT` open beside his **saved**
`C:\Users\drew\validation\First.step-1.SLDPRT`. Asking by that title lets
SOLIDWORKS choose which one dies — and the response would have recorded the
temp's path as closed either way.
**The 254 green tests were not evidence about the close key**: the mock's drain
removed the document by its own unique internal key, so it always closed exactly
the right one. Same failure of the mock as pass 8 (the root always agreed) and
pass 5's F3 (the path was always empty), in a new place.
0.5.6:
* **the victim is closed by the identity it was chosen by** — its full path. For
an import temporary `GetPathName` IS the `IC~~` path, which is what qualified it
in the first place. Never the bare title, here or in `close_doc` (which passes
the path whenever the document has one; `resolve` already refused an ambiguous
title before it).
* **a title collision is refused, not resolved.** Every document the walk passes
contributes `(title, path)` — the title is read for all of them, not just the
victims, which is one extra call per document inside an already bounded window —
and any victim whose title another open document shares is skipped and reported
as `titleCollisions: [{title, paths}]`. The hint says what only the user can
decide: close it yourself, or save the other document under another name. The
scan deliberately does **not** stop at `max`, because stopping it would blind
the collision check to a document later in the window.
* **every close is verified by path.** `GetOpenDocumentByName(path)` is one call:
after a close that reported success, the document must be gone. If it is still
open then something else was closed, and the drain **stops immediately** and
reports `wrong_document_closed` as an error with everything measured. That
branch must be unreachable; the check is the proof rather than the hope.
* **the mock now models `CloseDoc`'s by-name resolution** — a full path matches
only itself, a title resolves to the FIRST open document carrying it — so
`a_saved_document_sharing_a_temps_title_survives_the_drain` is a real test
instead of a pin, and a future close-by-title would fail the suite.
* **a walk that ended early is not reported as complete.** `walk_documents` now
returns WHY it stopped (`reachedEnd` / `hitMax` / `timedOut` /
`iterationFailed`), because a `GetNext` that fails on an invalidated iteration —
a document closed under us — is not the end of the session. `walkComplete` and
`walkStopped` are in the response and `done` requires `reachedEnd`, no
collisions and no failed closes.
## Adversary round 10 → the close key is right; the WINDOW it reasons over was not (0.5.7)
Pass 10 confirmed the pass-9 fix at the mechanism (the fixture scenario — two
temps plus a saved same-titled document, bridge-launched — is safe) and found that
everything *around* the close still spoke as though it had seen the session.
* **`done` did not consult `WalkStop::complete()`.** `hit_limit` was seeded from
`walk_stop == TimedOut` alone, so a walk that stopped at its scan cap
(`hitMax`) or on an invalidated iteration (`iterationFailed`) was treated as one
that reached the end. Measured on the adversary's fixture: 55 of 60 documents
scanned, a temporary sitting at #60 still open, `done:true`. It is now
`done = reachedEnd && scanned == documentCountBefore && no collisions && no
failed closes && the batch limit was not hit && no root mismatch`.
* **`walkStop` is published** (`reachedEnd` / `hitMax` / `timedOut` /
`iterationFailed`). It was computed, named, and never sent — so a caller could
not tell those four apart, and `titleCollisions: []` was uninterpretable.
* **The collision claim is scoped to the window it was actually checked over.**
`titleCollisionsCheckedOver: documentsScanned`, and the hint says so in words:
collisions were checked among the N documents this call scanned, not across the
session. A same-titled document beyond the window is invisible to the check —
harmless for the close, because the path is the key, and load-bearing for the
report.
* **The mock is window-bound now.** It selected victims and checked collisions
over the WHOLE session while reporting a 55-document scan, so it closed
documents its own reported window never reached — which is why 257 green tests
said nothing about any of the above. Victim selection, the collision check and
the near-miss count all run over the walked window only.
* **The `CloseDoc(path)` premise is now discriminated, not assumed.** The help
text says only *"name of the document to close"*; that a full path is accepted
has never been run live. If a close returns success and the document is still
open, one extra count settles which of two very different things happened:
* the count **did not move** → the call did nothing. That is a CAPABILITY
failure: `closeIgnoredPath:true`, `errorCode:"close_by_path_unsupported"`, the
drain stops, and **it does not fall back to a title** — that is exactly the
ambiguity round nine removed. An unreadable count reads as this case, because
it is the one that accuses nobody.
* the count **dropped** while our victim is still open → some other document
was closed: `wrong_document_closed`, stop, report loudly. Still unreachable;
still checked.
The mock models the failing premise (`mock-closedoc-ignores-path`: the path form
returns success and closes nothing), so the discriminator has a test.
* **The server forwards `closedDocs`** (title + path for every document closed)
and `titleCollisions`, not just counts. A caller needs to know WHICH files went.
`tools/drain-gate.py` is the live proof of the premise, on documents the bridge
makes itself: import the same fixture twice, save one copy under the same title
into a scratch dir, then ONE `swx_close_import_temps {batch:1}` and assert that
exactly one document closed, that it was named by its `IC~~` path, that the saved
twin is still open, that the count dropped by exactly one, that the collision was
reported, and that every document open at baseline is still open. It closes only
what it created and removes its own scratch directory.
## Adversary round 11 → the discriminator's third case, and the gate's own honesty (0.5.8)
**The unreadable count took the benign arm.** When `CloseDoc(path)` returned
success and the document was still open, 0.5.7 asked whether the open-document
count had moved — and an unreadable count fell into `_ => true`, meaning "the call
did nothing": `close_by_path_unsupported`, a capability problem, nobody accused.
But **a count can become unreadable *because* something large just changed**.
Closing a big assembly is exactly the kind of event that makes
`GetDocumentCount` stop answering, so the reassuring reading is the one this
bridge may not take. There are three outcomes, not two:
| after a close that "succeeded" while the victim is still open | verdict |
|---|---|
| the count did not move | the call did nothing — `close_by_path_unsupported` |
| the count DROPPED | another document was closed — `wrong_document_closed` |
| **the count cannot be read** | **`close_outcome_unknown`, `outcome:"undetermined"`** |
The third stops the drain and says what it does not know: the victim's path, the
count immediately before the close, and *"a human must check whether this path, or
another document, closed"*. It never claims nothing happened.
**And it had no fixture.** The mock read its count from `self.docs.len()`, so
`mock-doccount-none` could not reach the drain and that arm was dead code as far
as the suite was concerned — the arm that decides whether a user's document was
closed. The mock now reads every count through one seam that honours the fault.
**The gate's own claims got the same treatment** (`tools/drain-gate.py`):
* the PASS banner is conditional. With the saved twin never open it prints
**PARTIAL** — the close key is proven, the collision half is untested — instead
of a pass that covers something it did not exercise.
* step 3 now tries the twin save in the **default posture first**, with no
`allowUserWrites`, and records `defaultGateAllowedScratchSave` as a finding. A
bridge-created document written to a non-existing path inside a scratch dir the
gate made is exactly what the choke-point rule is supposed to allow, so a
refusal there is a BRIDGE BUG the gate names rather than works around. It then
retries with the opt-in so the rest of the run still happens; the finding stands
either way. (It does not reproduce in the mock — the rule is now pinned by
`the_default_posture_lets_the_bridge_save_its_own_document_to_a_new_path`, which
also pins that overwriting an EXISTING file still refuses.)
* cleanup deletes **only the two files the gate wrote**, by name, and removes the
directory only if it is then empty. A recursive delete of a directory whose
contents we did not write is the same class of act this bridge refuses
everywhere else — and SOLIDWORKS puts its own artefacts in there.
## Adversary round 12 → the write gate's own unanswerable question (0.5.9)
Not about the drain at all: about the choke point every write in this bridge goes
through. The destination test was
```rust
let clobbers = destination.map(|d| !d.is_empty() && Path::new(d).exists());
```
and `Path::exists()` returns **false for two different facts**: nothing is there,
and *I could not look* — a parent directory this process cannot traverse, a UNC
share it has no token for, a device that is not ready, a path over the length
limit. The gate read the second as "the destination is free" and allowed the
write with no opt-in, no confirm and no reason.
It matters because **the two processes do not share a filesystem view.** The com
host is a child of Adom Bridge; SOLIDWORKS runs in the user's interactive session
with the user's network token. `\\server\share\part.sldprt` that the bridge cannot
stat is a file SOLIDWORKS can open and overwrite — with the gate having already
said yes. This was the last place in the bridge where an unanswerable question
read as permission.
0.5.9 gives the question three answers, in one shared function
(`writes::destination_state`) used by **both** enforcement layers:
| `symlink_metadata` | verdict |
|---|---|
| `Ok(_)` | **Occupied** — overwriting needs the full opt-in |
| `Err(NotFound)` | **Free** — a bridge-created document may be written there with no opt-in |
| any other `Err` | **Unverifiable** — REFUSE, `destination_unverifiable` |
`symlink_metadata` rather than `try_exists` deliberately: a dangling symlink IS
something at that path, and writing through it lands somewhere the caller did not
name. The refusal happens *before* the opt-in is consulted, and the hint says why:
the opt-in answers "may I overwrite this?", and this is "I do not know what is
there" — so the opt-in cannot waive it. Both layers are tested: the server's via
`swx_save_as`, and the com host's by driving its stdio protocol directly **with
the opt-in fully granted**, so nothing but its own veto can be what refuses.
**And the sweep item, which is the same defect one layer down.** The server read
`closeOutcomeUnknown` with `unwrap_or(false)` — so a com host too old to send that
field reported "the outcome was determined; nobody needs to look". The com host
and the server are separate binaries and can be a mismatched pair (a half-applied
update, a stale exe in a cache dir). Now: the `ready` handshake carries the host's
version and a skew is an explicit boot error naming both versions, and the field
itself is read as `Option<bool>` where **absent beside a reported close failure is
treated as undetermined**.