main
Drew Owens 0.11.3 source 7364e19 5d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028
//! The 23 `swx_*` verbs.
//!
//! Every response ab relays VERBATIM to the calling AI, so the `_hint` on each
//! one IS this bridge's documentation — the part that actually gets read. The
//! rules the shapes follow:
//!
//!   * `success` is always present and boolean.
//!   * failures carry a stable, branchable `errorCode` when the caller can act
//!     on it (`host_app_not_installed`, `reason_required`) and `_reportIssues`
//!     so a real-machine bug can be filed without asking how.
//!   * anything that touches a window carries `windowEtiquette:"background"` —
//!     this bridge never steals the user's screen.
//!   * slow verbs carry a terminal `progress` frame, and `swx_progress` reports
//!     what is in flight right now.
//!
//! Verbs that do NOT need COM (status, readiness, describe, progress,
//! find_files, uninstall) work with SOLIDWORKS absent — that is the difference
//! between a bridge that is useless before the trial serial arrives and one that
//! can already answer "what do I have?". swx_screenshot is gated even though it
//! delegates to ab: there is nothing to screenshot without a running SOLIDWORKS
//! window, so it returns host_app_not_installed rather than an empty capture.

use crate::comhost::{ComHost, SidecarError};
use crate::detect::{detect, Detection};
use crate::jobs::Jobs;
use serde_json::{json, Map, Value};
use std::sync::{Arc, RwLock};
use std::time::Duration;

/// The bridge's own wiki page — where bugs in THIS bridge go.
const SLUG: &str = "adom/solidworks-bridge";

pub const TRIAL_HINT: &str =
    "SOLIDWORKS is not installed on this machine. A 30-day desktop trial was requested from \
     Hawk Ridge Systems (reseller) — the serial number arrives by email, then the Installation \
     Manager installs it. Until then only the detection/file-search verbs work (swx_status, \
     swx_readiness, swx_describe, swx_find_files, swx_progress). Do NOT tell the user to install \
     it by hand; offer to walk the install once the serial exists.";

pub fn report_issues() -> String {
    format!(
        "THIS MAY BE A SOLIDWORKS BRIDGE BUG. PLEASE REPORT IT. Check for a duplicate with \
         `adom-wiki issue list {SLUG}`, then file: `adom-wiki issue create {SLUG} --title \
         \"<one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, \
         what happened, the VERBATIM response JSON, the bridge version from bridge_list, and the \
         OS>\"`. Reports from real machines are the only way this bridge gets correct. (An \
         ab-core problem, such as the relay, bridge lifecycle or caller identity, belongs on \
         adom/adom-bridge instead.)"
    )
}

/// Everything a verb needs: the com host, the progress registry, and the last
/// detection result.
pub struct Ctx {
    pub com: Arc<ComHost>,
    pub jobs: Arc<Jobs>,
    pub detection: RwLock<Detection>,
    /// The documents THIS SERVER PROCESS created and has not closed — what
    /// `swx_reset_workspace` may sweep and what the mutating verbs may change.
    ///
    /// An entry is (title, SOLIDWORKS session, path-once-saved). All three
    /// matter, and the first version had only the title:
    ///
    ///   * SOLIDWORKS names new documents `Part1`, `Part2`, `Assem1` — and it
    ///     RESTARTS that numbering with every session. A remembered `Part1`
    ///     would therefore match a fresh `Part1` the USER made after a restart,
    ///     and the bridge would treat their document as its own. Hence the
    ///     session (SOLIDWORKS' own process id): an entry from another session
    ///     is never owned.
    ///   * Titles were also forgotten only on the paths that run through this
    ///     bridge. A document closed in the SOLIDWORKS UI, by a crash, or by
    ///     `CloseAllDocuments` would linger as "ours" forever. So ownership is
    ///     additionally checked against what SOLIDWORKS reports as OPEN RIGHT
    ///     NOW, and stale entries are pruned there.
    ///
    /// The earlier comment claiming "a restart forgets" was false: the bridge
    /// server outlives SOLIDWORKS.
    pub scratch_docs: std::sync::Mutex<Vec<ScratchDoc>>,
}

/// Are these the same Windows path?
///
/// Windows paths are CASE-INSENSITIVE, and SOLIDWORKS normalises what it reports
/// — ask it to save `…\smoke.sldprt` and it will tell you the document lives at
/// `…\smoke.SLDPRT`. An exact string compare therefore said the bridge's own
/// freshly-saved scratch document was somebody else's: the ownership record kept
/// the path we ASKED for, the prune compared against the path SOLIDWORKS
/// REPORTED, they differed by one letter's case, and the bridge disowned its own
/// document and refused to clean it up.
///
/// Separators are normalised too, for the same reason: `\` and `/` both reach
/// the same file and a caller may write either.
pub fn same_path(a: &str, b: &str) -> bool {
    fn norm(s: &str) -> String {
        s.replace('/', "\\").to_lowercase()
    }
    !a.is_empty() && !b.is_empty() && norm(a) == norm(b)
}

/// A document TITLE without its SOLIDWORKS extension (0.10.10). SOLIDWORKS'
/// `GetTitle` follows Windows Explorer's "Hide extensions for known file
/// types" (HideFileExt): with it ON — the Windows DEFAULT — a saved part is
/// titled `Part14-17`, with it off `Part14-17.SLDPRT`. MEASURED 2026-10-15 on
/// Michael's SOLIDWORKS 2021: every verb the self-test aimed at its scratch
/// part by the title WITH the extension failed `document_not_open`. Only a
/// trailing `.sldprt` / `.sldasm` / `.slddrw` (any case) is removed: a dot
/// elsewhere (`Rev1.2`) is part of the name.
pub fn title_stem(t: &str) -> &str {
    let t = t.trim_end();
    for ext in [".sldprt", ".sldasm", ".slddrw"] {
        if t.len() > ext.len() && t.is_char_boundary(t.len() - ext.len()) && t[t.len() - ext.len()..].eq_ignore_ascii_case(ext) {
            return &t[..t.len() - ext.len()];
        }
    }
    t
}

/// THE ONE TITLE COMPARISON (0.10.10): equal once the SOLIDWORKS extension is
/// dropped from both sides — a title match must never depend on HideFileExt.
pub fn same_title(a: &str, b: &str) -> bool {
    !a.trim().is_empty() && !b.trim().is_empty() && title_stem(a) == title_stem(b)
}

/// IS THIS OPEN DOCUMENT THE ONE WE RECORDED?
///
/// The single comparison both the ownership check and the prune must use.
/// They used different ones: `owns_doc` compared paths with [`same_path`]
/// (case- and separator-insensitive, which is what Windows means by "the same
/// file"), while `prune_scratch` compared them BYTE-EXACT. SOLIDWORKS hands the
/// same file back in different casings from different APIs, so a document could
/// pass the ownership test and still be pruned out of the registry that grants
/// it — and the next write to it was refused as "this bridge did not create
/// it". Bug #5's user-visible symptom, from a different cause.
///
/// A document with a PATH is identified by the path. One that has never been
/// saved is identified by title, and only against another unsaved document —
/// an entry we have since saved refers to a file, not to whatever now carries
/// its old name.
pub fn doc_matches(
    stored_title: &str,
    stored_path: Option<&str>,
    open_title: &str,
    open_path: Option<&str>,
) -> bool {
    match (stored_path, open_path) {
        (Some(a), Some(b)) => same_path(a, b),
        (None, None) => same_title(stored_title, open_title),
        _ => false,
    }
}

/// One document this bridge created. See [`Ctx::scratch_docs`].
#[derive(Clone, Debug, PartialEq)]
pub struct ScratchDoc {
    pub title: String,
    /// SOLIDWORKS' own process id when this document was created. `None` means
    /// the session could not be read, which counts as NOT OWNED.
    pub session: Option<i64>,
    /// Filled in once saved, so a retitle is survivable.
    pub path: Option<String>,
}

/// The document a mutating verb is about to change, resolved ONCE on the server
/// and then handed to the com host EXPLICITLY.
///
/// This type exists because of a real defect. The ownership gate resolved a
/// target from `targetDocument`, but only `swx_run_script` forwarded that
/// argument — every other verb sent `path` (usually null), so the com host fell
/// back to ActiveDoc and changed a DIFFERENT document from the one the gate had
/// authorized. `swx_save_as {outputPath:"…", targetDocument:"Part1"}` renamed
/// and relocated the user's open part: the v0.2 data-loss bug, resurrected by
/// its own fix. Carrying the resolution through is the answer — whatever the
/// gate approved is exactly what the com host is told to act on.
#[derive(Clone, Debug)]
pub struct Target {
    pub title: String,
    pub path: Option<String>,
    pub owned: bool,
}

impl Target {
    /// The identity args every mutating verb must send. `docName` is a
    /// dedicated key precisely so it cannot collide with the `name` argument
    /// that means a DIMENSION on swx_set_parameter and a PREFERENCE on
    /// swx_set_preference.
    pub fn args(&self) -> Value {
        json!({"docName": self.title, "path": self.path})
    }

    /// How to name this document in a message to a human.
    pub fn describe(&self) -> String {
        match &self.path {
            Some(p) => format!("{} ({p})", self.title),
            None => format!("{} (unsaved)", self.title),
        }
    }
}

impl Ctx {
    pub fn new(detection: Detection) -> Self {
        // BUG #5. The registry is loaded from disk, not started empty: a bridge
        // restart used to disown every document the bridge had created, and on
        // 2026-09-03 that made 23 of its own crate documents un-closable and
        // un-savable. Entries whose SOLIDWORKS process id has since been reused
        // by a different process are dropped on the way in (see
        // `crate::owned::reconcile`) — this can only ever REMOVE a claim.
        let restored: Vec<ScratchDoc> = crate::owned::restore();
        Self {
            com: Arc::new(ComHost::new()),
            jobs: Jobs::new(),
            detection: RwLock::new(detection),
            scratch_docs: std::sync::Mutex::new(restored),
        }
    }

    /// Write the registry through to disk. Called after every change, so the
    /// file is the truth a restart (or a second look after a com-host respawn)
    /// reads back.
    fn persist_scratch(&self) {
        let docs = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).clone();
        crate::owned::save(&docs, crate::owned::session_started_at);
    }

    /// Remember a document this bridge created, so the sweep may close it.
    pub(crate) fn remember_scratch(&self, title: &str, session: Option<i64>) {
        if title.is_empty() {
            return;
        }
        {
            let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
            if !v.iter().any(|d| same_title(&d.title, title) && d.session == session) {
                v.push(ScratchDoc { title: title.to_string(), session, path: None });
            }
        }
        self.persist_scratch();
    }

    /// Remember a document the bridge created that ALREADY HAS A PATH — an
    /// import, whose `GetPathName` is its `…\Temp\swx<pid>\IC~~\…` conversion
    /// temp even though it was never saved anywhere real.
    ///
    /// A NEW entry, never a mutation of an existing one. `rename_scratch` finds
    /// its target BY TITLE, so using it here would reassign the recorded path of
    /// a *different* owned document that happens to share the imported title —
    /// SOLIDWORKS titles an imported STEP `<stem>.SLDPRT`, so importing beside a
    /// saved part of the same name is the ordinary case, and that would have
    /// disowned the user-facing document (the collision `resolve` already warns
    /// about, arriving from the ownership side instead).
    pub(crate) fn remember_scratch_at(&self, title: &str, session: Option<i64>, path: &str) {
        if title.is_empty() || path.is_empty() {
            return;
        }
        {
            let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
            let already = v.iter().any(|d| {
                d.session == session
                    && d.path.as_deref().map(|p| same_path(p, path)).unwrap_or(false)
            });
            if !already {
                v.push(ScratchDoc {
                    title: title.to_string(),
                    session,
                    path: Some(path.to_string()),
                });
            }
        }
        self.persist_scratch();
    }

    #[cfg(test)]
    pub(crate) fn forget_scratch(&self, title: &str) {
        self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).retain(|d| !same_title(&d.title, title));
        self.persist_scratch();
    }

    /// Forget the ONE document that was closed — matched the way ownership is
    /// decided ([`doc_matches`]: by path when it has one), not by title. A
    /// title-only forget dropped every owned document that shared the closed
    /// one's name, so the bridge disowned a second document it had made
    /// (0.9.29 audit, prompted by issue #2's title-only ownership test).
    pub(crate) fn forget_scratch_doc(&self, title: &str, path: Option<&str>) {
        let path = path.filter(|p| !p.is_empty());
        self.scratch_docs
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .retain(|d| !doc_matches(&d.title, d.path.as_deref(), title, path));
        self.persist_scratch();
    }

    /// A scratch document that has been saved keeps its identity under its new
    /// title, so `save_as` does not silently disown it.
    #[cfg(test)]
    pub(crate) fn rename_scratch(&self, from: &str, to: &str, path: Option<String>) {
        self.rename_scratch_doc(from, None, to, path, true)
    }

    /// Rename the ONE entry that IS the document saved — by path when it had
    /// one (0.9.29 audit). `title_fallback`: an entry for a never-saved
    /// document is found by title, as before.
    pub(crate) fn rename_scratch_doc(&self, from: &str, from_path: Option<&str>, to: &str, path: Option<String>, title_fallback: bool) {
        {
            let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
            let from_path = from_path.filter(|p| !p.is_empty());
            let hit = v.iter().position(|d| doc_matches(&d.title, d.path.as_deref(), from, from_path)).or_else(|| {
                if title_fallback { v.iter().position(|d| same_title(&d.title, from)) } else { None }
            });
            if let Some(d) = hit.map(|i| &mut v[i]) {
                d.title = to.to_string();
                d.path = path;
            }
        }
        self.persist_scratch();
    }

    /// Drop every entry that cannot still be ours: a different SOLIDWORKS
    /// session (its Part numbering restarts, so titles repeat), or a document
    /// that is no longer open at all.
    /// Drop every entry that cannot still be ours: a different SOLIDWORKS
    /// session, or a document that is no longer open. `open` is (title, path)
    /// for each open document — matched on PATH where the entry has one,
    /// because a title alone cannot tell two documents apart.
    pub(crate) fn prune_scratch(&self, session: Option<i64>, open: &[(String, Option<String>)]) {
        // BUG #5, THE OTHER HALF. `session` is None exactly when SOLIDWORKS
        // could not be asked — a com-host respawn mid-call, a timeout, a wedged
        // app. The old code pruned anyway, and since EVERY entry fails the
        // `d.session == None` test, one unanswerable probe permanently disowned
        // every document the bridge had created. An unreadable session is
        // "I do not know", and I-do-not-know is not grounds to forget.
        if session.is_none() {
            return;
        }
        {
            let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
            v.retain(|d| {
                d.session.is_some()
                    && d.session == session
                    // ONE comparison, shared with `owns_doc`. See `doc_matches`.
                    && open.iter().any(|(t, op)| {
                        doc_matches(&d.title, d.path.as_deref(), t, op.as_deref())
                    })
            });
        }
        self.persist_scratch();
    }

    /// (title, path) for every document currently believed to be ours — the
    /// PATH is what a caller must use to name one unambiguously.
    pub(crate) fn scratch_paths(&self) -> Vec<(String, Option<String>)> {
        self.scratch_docs
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .iter()
            .map(|d| (d.title.clone(), d.path.clone()))
            .collect()
    }

    /// Titles currently believed to be ours (as of the last prune).
    pub(crate) fn scratch_titles(&self) -> Vec<String> {
        self.scratch_docs
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .iter()
            .map(|d| d.title.clone())
            .collect()
    }

    /// FAIL CLOSED: with no session we cannot tell our `Part1` from the user's.
    ///
    /// A TITLE IS NOT AN IDENTITY. `C:\users\thing.sldprt` and
    /// `C:\bridge\thing.sldprt` are both titled `thing.sldprt`, and having two
    /// such documents open is ordinary. So when the document has a PATH, that is
    /// what ownership is decided on; the title only decides for a document that
    /// has never been saved, which cannot collide because SOLIDWORKS will not
    /// issue the same untitled name twice in one session.
    pub(crate) fn owns_doc(&self, title: &str, path: Option<&str>, session: Option<i64>) -> bool {
        if session.is_none() {
            return false;
        }
        let docs = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
        let path = path.filter(|p| !p.is_empty());
        docs.iter()
            .any(|d| d.session == session && doc_matches(&d.title, d.path.as_deref(), title, path))
    }

    pub fn detection(&self) -> Detection {
        self.detection.read().unwrap_or_else(|e| e.into_inner()).clone()
    }

    pub(crate) fn redetect(&self) -> Detection {
        let fresh = detect();
        *self.detection.write().unwrap_or_else(|e| e.into_inner()) = fresh.clone();
        fresh
    }

    pub(crate) fn call(&self, cmd: &str, args: Value, secs: u64) -> Result<Value, SidecarError> {
        self.com.call(cmd, args, Duration::from_secs(secs))
    }
}

// ── response builders ───────────────────────────────────────────────────────

/// `{success:true, ...result, ...extras}` — extras win, exactly like the Python
/// `{"success": True, **r, "extra": ...}` this port replaces.
pub(crate) fn ok_merge(result: Value, extras: Value) -> Value {
    let mut out = Map::new();
    out.insert("success".into(), Value::Bool(true));
    for source in [result, extras] {
        if let Value::Object(map) = source {
            for (k, v) in map {
                out.insert(k, v);
            }
        }
    }
    Value::Object(out)
}

pub fn ok(extras: Value) -> Value {
    ok_merge(json!({}), extras)
}

pub(crate) fn err(message: impl Into<String>, hint: impl Into<String>) -> Value {
    json!({"success": false, "error": message.into(), "_hint": hint.into()})
}

pub fn err_code(code: &str, message: impl Into<String>, hint: impl Into<String>) -> Value {
    json!({"success": false, "errorCode": code, "error": message.into(), "_hint": hint.into()})
}

/// A failure that came back from the com host. `detail` is the host's stderr
/// tail — the difference between "it broke" and "here is what it said".
pub(crate) fn sidecar_error(e: SidecarError) -> Value {
    json!({
        "success": false,
        // The host's own code where it gave one, so a caller can branch instead
        // of pattern-matching prose. The live run returned four failures with
        // `errorCode: None` and nothing but a sentence.
        "errorCode": e.code,
        "error": e.message,
        "detail": e.detail,
        // WHAT THE FAILING CALL SAID, structured, when the host had it. A
        // translator failure diagnosed from prose that omits the HRESULT is not
        // diagnosed at all.
        "hostError": e.data,
        // A CODED REFUSAL IS ITS OWN EXPLANATION, and the generic sentence must
        // not compete with it. `interconnect_blocks_import` carried "If SOLIDWORKS
        // is showing a modal dialog, dismiss it and retry" — there is no dialog,
        // nothing is hung, and retrying changes nothing until the user acts on the
        // setting. This is the 0.7.6 defect (a blanket instruction that was wrong
        // for the specific item) in a second place, and `_hint` is the field
        // agents read first.
        "_hint": if e.code.is_some() {
            "The COM host REFUSED this, with a reason: read `errorCode` and `detail` — they say \
             what would change the answer. `hostError` carries the structured failure where there \
             was one. The host process is restarted automatically on the next verb."
        } else {
            "The COM host reported an error. If SOLIDWORKS is showing a modal dialog, dismiss it \
             (or ask the user to) and retry; swx_status shows whether SOLIDWORKS is running and \
             attached at all. The host process is restarted automatically on the next verb."
        },
    })
}

// ── the host-app gate ───────────────────────────────────────────────────────

/// Every COM-backed verb starts here: with SOLIDWORKS absent, fail fast with a
/// branchable code and the current install story, instead of a COM error the
/// caller cannot act on.
pub(crate) fn host_gate(ctx: &Ctx) -> Option<Value> {
    gate_for(crate::is_mock(), ctx.detection().installed)
}

/// May `swx_start` launch SOLIDWORKS?
///
/// Mock mode says yes so the suite can drive the launch path — EXCEPT under
/// `SWX_TEST_NO_LAUNCH`, which exists because the refusal branch is the one that
/// matters on the machine this bridge actually runs on, and it was previously
/// unreachable from any test on any platform. (A guard nothing can execute is
/// not a guard, it is a hope.)
pub fn may_launch(mock: bool, detection: &Detection) -> bool {
    if mock {
        return std::env::var("SWX_TEST_NO_LAUNCH").as_deref() != Ok("1");
    }
    detection.can_launch()
}

/// The gate itself, as a pure decision — the branch that matters most on a
/// machine where SOLIDWORKS has not arrived yet, and the one that cannot be
/// reached from a Linux test host any other way.
pub fn gate_for(mock: bool, installed: bool) -> Option<Value> {
    if mock || installed {
        return None; // the mock backend serves every verb
    }
    Some(err_code("host_app_not_installed", "SOLIDWORKS is not installed", TRIAL_HINT))
}

macro_rules! gate {
    ($ctx:expr) => {
        if let Some(g) = $crate::verbs::host_gate($ctx) {
            return g;
        }
    };
}
pub(crate) use gate;

pub(crate) fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
    args.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty())
}

pub(crate) fn arg_bool(args: &Value, key: &str, default: bool) -> bool {
    args.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
}

/// Validate a caller-supplied destination path. A relay AI's `out` must not be
/// a relative path (which would resolve against the bridge's working directory —
/// a `../../victim` traversal off the desktop) and must not contain a `..`
/// component. An ABSOLUTE path is honoured: choosing where an export or a BOM
/// lands on one's own desktop is the whole point of `out`; escaping the process
/// cwd with a relative path is not.
pub(crate) fn safe_out(out: &str) -> Result<String, Value> {
    let path = std::path::Path::new(out);
    // A leading drive letter only counts as absolute when a separator FOLLOWS
    // the colon: `C:\x` / `C:/x` are rooted, but `C:foo` is DRIVE-RELATIVE (it
    // resolves against the cwd on drive C) and must be refused like any other
    // relative path.
    let drive_rooted = {
        let b = out.as_bytes();
        b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
    };
    let absolute = path.is_absolute()
        || out.starts_with('\\') // UNC / rooted-from-current-drive on Windows
        || drive_rooted;
    if !absolute {
        return Err(err_code(
            "unsafe_path",
            format!("out must be an absolute path, got a relative one: {out}"),
            "Pass an absolute destination (e.g. C:\\\\exports\\\\part.step). A relative path \
             would resolve against the bridge's own working directory, which is a traversal \
             risk; omit `out` entirely to let the file land in the bridge's pruned exports cache.",
        ));
    }
    if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
        return Err(err_code(
            "unsafe_path",
            format!("out must not contain a '..' component: {out}"),
            "Give a direct absolute path with no parent-directory traversal.",
        ));
    }
    Ok(out.to_string())
}

// ── verbs: bridge + detection ───────────────────────────────────────────────

fn swx_status(ctx: &Ctx, _args: &Value) -> Value {
    let detection = ctx.redetect();
    // swx_status is the declared statusVerb — ab tells callers to POLL it when a
    // long verb times out. So it must NOT block on the com host's request lock,
    // which a 580s Pack and Go could be holding. Read a non-blocking snapshot
    // first; only query the host for live COM state when it is actually idle.
    let snap = ctx.com.snapshot();
    // `com_timed_out` MUST mean "a COM call exceeded its budget". It used to be
    // `error present OR the host is busy` — and the host is busy exactly when
    // THIS BRIDGE is running a long verb, which is exactly when SOLIDWORKS
    // stops pumping its message loop. Two "independent" signals from one cause,
    // and the user's working session reported as hung. Busy is now UNKNOWN.
    let mut com_timed_out: Option<bool> = None;
    let com = if snap.busy {
        json!({
            "busy": true,
            "hostPid": snap.pid,
            "inFlight": snap.in_flight,
            "note": "a long-running verb holds the COM host; poll swx_progress for its live frame",
        })
    } else {
        match ctx.call("status", json!({}), 20) {
            Ok(mut v) => {
                com_timed_out = Some(false);
                if let Value::Object(map) = &mut v {
                    // Post-call: the status query spawns the child if needed, so
                    // read the pid AFTER, not from the pre-call snapshot.
                    map.insert("hostPid".into(), json!(ctx.com.pid()));
                    map.insert("busy".into(), json!(false));
                }
                v
            }
            Err(e) => {
                // Only a BUDGET OVERRUN is a timeout, and the com host says so
                // itself rather than this line re-deciding it from the wording
                // of a message. An UNRECOGNISED failure is UNKNOWN, not
                // "did not time out": a crashed child tells us nothing either
                // way about whether SOLIDWORKS is wedged.
                com_timed_out = if e.timed_out { Some(true) } else { None };
                json!({
                    "error": e.message,
                    "hostPid": ctx.com.pid(),
                    "timedOut": e.timed_out,
                    "note": "a com-host failure that is not a timeout says nothing about whether \
                             SOLIDWORKS itself is responding",
                })
            }
        }
    };
    let hint = if !detection.installed && !crate::is_mock() {
        TRIAL_HINT.to_string()
    } else {
        "swx_docs lists open documents; swx_open opens one (background). swx_bom / \
         swx_mass_properties / swx_interference are the analysis verbs; swx_find_files searches \
         disk without needing SOLIDWORKS open. `launch` reports any launch in flight (stage, \
         route, routes already tried); `solidworks.responding` / `.privateMemoryMB` / `.handles` \
         are read WITHOUT COM, so they still answer when SOLIDWORKS is wedged."
            .to_string()
    };
    // THE HALF COM CANNOT ANSWER. `responding`, memory and handles are read
    // from the OS — which is the whole point, because the moment those numbers
    // matter is the moment COM has stopped replying. A com `status` that timed
    // out is the COM half of the hang test; IsHungAppWindow is the other half.
    let os = crate::launchos::os();
    let open_docs = com.get("openDocs").and_then(|v| v.as_u64());
    let com_pid = com.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32);
    // THE SOLIDWORKS WE ARE ATTACHED TO, for the lineage walk: with more than one
    // sldworks.exe on the machine (a zombie from a crash, or the user's own
    // session beside ours) the one a caller MEANS is the one COM answers for.
    crate::launch::note_attached_solidworks(com_pid);
    // WHICH SOLIDWORKS THE LAST LAUNCH STARTED (0.10.7), decided once it is
    // attached — the claim a recycle and the quit gate rely on.
    if com_pid.is_some() {
        crate::launch::attribute_on_attach(os.as_ref(), com_pid);
    }
    let health = crate::launch::host_app_health(os.as_ref(), open_docs, com_timed_out, com_pid);
    let mut solidworks = detection.to_json();
    if let (Value::Object(map), Value::Object(h)) = (&mut solidworks, &health) {
        for (key, v) in h {
            map.insert(key.clone(), v.clone());
        }
    }
    // THE VERSION, read at attach (0.10.0): RevisionNumber "29.5.1" is
    // SOLIDWORKS 2021 — the release issue #1/#2 were filed on. Supported vs
    // tested are different claims, and both are said (docs/COMPATIBILITY.md).
    if let Value::Object(map) = &mut solidworks {
        let revision = com.get("version").and_then(|v| v.as_str());
        let v = crate::compat::describe(revision);
        map.insert("majorVersion".into(), v["majorVersion"].clone());
        map.insert("versionSupport".into(), v);
    }
    ok(json!({
        "bridgeVersion": crate::BRIDGE_VERSION,
        "mock": crate::is_mock(),
        "userWrites": crate::writes::posture(),
        // SOLIDWORKS VISUALIZE (0.9.19): running, pid, main window, and what
        // it is waiting on (it is never answered by the bridge).
        "visualize": crate::visualize::status(),
        // TOLD WHETHER COM IS ATTACHED, because that is the fact that decides
        // whether a session which came up after a sign-in may be re-claimed as
        // ours — and only this verb has it.
        "launch": crate::launch::report_with_attach(
            os.as_ref(),
            com.get("running").and_then(|v| v.as_bool()).unwrap_or(false)
                || com.get("attached").and_then(|v| v.as_bool()).unwrap_or(false),
        ),
        // Whether the ab-delegated verbs (window info, dialogs, screenshot,
        // taskbar badge, kill) can work at all. A bridge started by hand rather
        // than spawned by Adom Bridge never inherits ADOM_DIRECT_API_URL, and
        // every one of those verbs then fails for a reason that has nothing to
        // do with SOLIDWORKS.
        "abAvailable": crate::caller::ab_available(),
        "solidworks": solidworks,
        "com": com,
        // THE MACHINE'S MEMORY (0.10.5): the commit charge, SOLIDWORKS's own
        // share, and the largest holders — read-only facts. Read it before a
        // long run (the solidworks-memory skill).
        "memory": crate::memory::report(os.as_ref()),
        // IS THE USER AT THE MACHINE? (0.10.9) Read without COM, every time, so a
        // battery can check it in the same call as memory.
        "userPresence": crate::presence::activity().to_json(),
        // A self-test cut off by a client timeout leaves its parts open
        // (measured 2026-10-15): REPORTED here, closed by the next swx_selftest.
        "orphanedSelftestDocuments": crate::selftest::orphan_report(),
        "windowEtiquette": "background",
        "_hint": hint,
    }))
}

fn swx_readiness(ctx: &Ctx, _args: &Value) -> Value {
    let d = ctx.detection();
    let ready = crate::is_mock() || d.installed;
    let hint = format!(
        "Read-only; never spawns SOLIDWORKS. {}",
        if ready {
            "Ready — swx_start launches the app when a verb needs it."
        } else {
            TRIAL_HINT
        }
    );
    ok(json!({
        "ready": ready,
        "installed": d.installed,
        "running": d.running,
        "mock": crate::is_mock(),
        "trialPending": !d.installed && !crate::is_mock(),
        "_hint": hint,
    }))
}

fn swx_state(ctx: &Ctx, args: &Value) -> Value {
    let detection = ctx.redetect();
    let mut docs = Value::Null;
    let mut active = Value::Null;
    let mut main_hwnd = Value::Null;
    let mut busy = Value::Null;
    if detection.running || crate::is_mock() {
        if let Ok(r) = ctx.call("app_state", json!({}), 30) {
            docs = r.get("documents").cloned().unwrap_or(Value::Null);
            active = r.get("activeDoc").cloned().unwrap_or(Value::Null);
            main_hwnd = r.get("mainHwnd").cloned().unwrap_or(Value::Null);
            busy = r.get("busy").cloned().unwrap_or(Value::Null);
        }
    }
    if docs.is_null() {
        docs = json!([]);
    }
    // THE DIALOG CENSUS (0.11.1): NATIVE and DETERMINISTIC — every top-level
    // window of SOLIDWORKS (and its CEF children), judged, every time, whether or
    // not a screenshot is wanted. MEASURED 2026-10-15: at 19:30Z this verb said
    // `blockingDialogs: []` while a File ▸ Open dialog (hwnd 205698) was up
    // before and after — the census went through ab's flat window list, and was
    // SKIPPED entirely (reporting a known-empty []) whenever screenshot:false.
    // ab's list is the fallback only where the native one cannot be read.
    let want_shot = arg_bool(args, "screenshot", true);
    let hwnd_i64 = main_hwnd.as_i64();
    let mut windows_error: Option<String> = None;
    let os = crate::launchos::os();
    let mut census_source = "native";
    let mut other_windows: Vec<Value> = Vec::new();
    let mut notifications: Vec<Value> = Vec::new();
    // A census that finds NO SOLIDWORKS process while COM says one is running
    // cannot vouch for anything: it is not "no dialogs".
    let sw_seen_running = detection.running || !main_hwnd.is_null();
    let dialogs: Option<Vec<Value>> = match crate::dialogs::census_windows(os.as_ref()).filter(|(_, pids)| !pids.is_empty() || !sw_seen_running) {
        Some((rows, _)) => {
            let judged = crate::quit::dialog_census(&rows, os.as_ref());
            notifications = judged.notifications.iter().map(|d| d.report(os.as_ref())).collect();
            // Owned popups that are not dialogs: listed, never judged away.
            other_windows = rows.iter().filter(|w| !w.is_dialog()).map(|w| w.to_json()).collect();
            Some(judged.all_blocking().iter().map(|d| d.report(os.as_ref())).collect())
        }
        None if detection.running || crate::is_mock() => {
            census_source = "ab";
            // AN UNREACHABLE ab IS NOT "NO DIALOGS": null, never [].
            match crate::abwin::list_windows(Duration::from_secs(20)) {
                Ok(w) => {
                    let sw_pid = ctx.call("app_state", json!({}), 20).ok().and_then(|s| s.get("processId").and_then(|p| p.as_i64()));
                    Some(crate::abwin::dialog_census(&w, hwnd_i64, sw_pid))
                }
                Err(e) => {
                    windows_error = Some(e);
                    None
                }
            }
        }
        None => {
            census_source = "none";
            windows_error = Some("the window census could not be taken".into());
            None
        }
    };
    let screenshot = if want_shot && (detection.running || crate::is_mock()) {
        hwnd_i64.map(|h| crate::abwin::screenshot(h, Duration::from_secs(45))).unwrap_or(Value::Null)
    } else {
        Value::Null
    };
    ok(json!({
        "running": detection.running,
        "solidworks": detection.to_json(),
        "openDocs": docs,
        "activeDoc": active,
        "activeDocument": active.get("title").cloned().unwrap_or(Value::Null),
        "mainHwnd": main_hwnd,
        "busy": busy,
        // `null` when the desktop could not be enumerated — never `[]`.
        "blockingDialogs": dialogs.clone().map(Value::from).unwrap_or(Value::Null),
        "blockingDialogsKnown": dialogs.is_some(),
        "blockingDialogsError": windows_error,
        "dialogCensusSource": census_source,
        "dialogNotifications": notifications,
        "ownedWindows": other_windows,
        "screenshot": screenshot,
        "timings": crate::ledger::stages(),
        "focusEvents": [],
        "windowEtiquette": "background",
        "_hint": "The one-call render-me verb: detection + open documents + the active one + \
                  every blocking dialog + a BACKGROUND screenshot of the SOLIDWORKS window \
                  (PrintWindow, so it works occluded or minimised and steals no focus) + this \
                  machine's measured verb timings. screenshot:false skips the ab callbacks when \
                  you only want the logical state. focusEvents is always empty by design — this \
                  bridge never takes the user's focus, so there is nothing to audit.",
    }))
}

fn swx_progress(ctx: &Ctx, _args: &Value) -> Value {
    ok(json!({
        "active": ctx.jobs.active(),
        "_hint": "Empty active[] means idle. Poll ~1/s while a slow verb runs to drive a live \
                  bar; each frame carries phase, stepLabel, elapsedSec and etaSec. Slow verbs \
                  also return their own terminal frame (percent:100) when they finish.",
    }))
}

// ── verbs: app lifecycle ────────────────────────────────────────────────────

/// `swx_start`, and then (0.11.2) the launch's own mess cleared: a File ▸ Open
/// with an empty name that a launch route provably raised is cancelled here
/// too — ledgered — so a launch never leaves the session blocked.
fn swx_start(ctx: &Ctx, args: &Value) -> Value {
    // A START RIGHT AFTER A QUIT waits for that session to be gone (0.11.3).
    let waited = if arg_str(args, "app").map(|a| a == "visualize").unwrap_or(false) {
        None
    } else {
        crate::launch::wait_for_previous_exit(crate::launchos::os().as_ref())
    };
    let mut out = swx_start_inner(ctx, args);
    if let (Some(w), Value::Object(m)) = (waited, &mut out) {
        m.insert("waitedForPreviousExit".into(), w);
    }
    let cancelled = crate::dialogs::cancel_launch_file_dialogs(crate::launchos::os().as_ref());
    if !cancelled.is_empty() {
        if let Value::Object(m) = &mut out {
            m.insert("dialogsCancelled".into(), json!(cancelled));
        }
    }
    out
}

fn swx_start_inner(ctx: &Ctx, args: &Value) -> Value {
    // SOLIDWORKS VISUALIZE (0.9.19): its platform shortcut, its own readiness.
    if arg_str(args, "app") == Some("visualize") {
        return crate::visualize::start(ctx, args);
    }
    if let Some(other) = arg_str(args, "app").filter(|a| *a != "solidworks") {
        return err_code("bad_app", format!("app {other:?} is not \"solidworks\" or \"visualize\""), "Omit app for SOLIDWORKS.");
    }
    gate!(ctx);
    let detection = ctx.redetect();
    // ATTACH FIRST. If SOLIDWORKS is already up, this is the whole verb — and on
    // SOLIDWORKS Connected it is the ONLY thing that can work.
    if let Ok(r) = ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": false}), 60) {
        // Whatever brought SOLIDWORKS up — our own supervisor, or the user
        // clicking their Start Menu while we watched — the attempt is over and
        // the route that was in flight is the one that worked.
        crate::launch::mark_attached();
        let pid = r.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32);
        crate::launch::attribute_on_attach(crate::launchos::os().as_ref(), pid);
        return ok_merge(
            r,
            json!({
                "wasLaunched": false,
                "attached": true,
                "stillLaunching": false,
                "edition": detection.edition(),
                "windowEtiquette": "background",
                "_hint": "Already running and COM-attached — nothing was launched. swx_open or \
                          swx_new_document next; swx_docs shows what is already loaded.",
            }),
        );
    }
    // Not running. Whether we may start it depends on the EDITION.
    //
    // LIVE FINDING (2026-09-02): on SOLIDWORKS Connected / 3DEXPERIENCE — which
    // includes "Design Professional for Makers" — CoCreateInstance spawns an
    // sldworks.exe that immediately refuses with "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. It
    // leaves a modal on the user's screen and a useless process behind. So this
    // bridge does not try: it says who has to do it and why.
    // THE SANCTIONED ROUTE, if this machine has one. See
    // `detect::launch_route_from`: SOLIDWORKS Connected refuses a DIRECT
    // sldworks.exe launch, but the shortcut the 3DEXPERIENCE Platform installed
    // runs CATSTART -> SWXDesktopLauncher with that user's tenant and registry,
    // and that is the launch Dassault sanctions. Our earlier "cannot be launched
    // programmatically" was a conclusion about the wrong experiment.
    if let crate::detect::LaunchRoute::PlatformShortcut(shortcut) = detection.launch_route() {
        return start_via_shortcut(ctx, args, &detection, &shortcut);
    }
    if !may_launch(crate::is_mock(), &detection) {
        return err_code(
            "host_app_launch_blocked",
            format!(
                "SOLIDWORKS is not running, and this edition ({}) cannot be started \
                 programmatically",
                detection.edition()
            ),
            "SOLIDWORKS Connected / 3DEXPERIENCE (including Design Professional for Makers) \
             refuses any launch that did not come from the 3DEXPERIENCE Platform: a COM launch \
             spawns a process that puts up \"SOLIDWORKS Design must be launched from the \
             3DEXPERIENCE Platform\" and never attaches. ASK THE USER to start SOLIDWORKS from \
             the 3DEXPERIENCE Platform or its desktop shortcut, then retry — everything else in \
             this bridge works normally once it is up. Do not try to launch sldworks.exe by \
             hand; it produces exactly that modal. \
             \
             THIS BRIDGE CAN NORMALLY START A CONNECTED EDITION ITSELF, by invoking the \
             shortcut the Platform installed (CATSTART -> SWXDesktopLauncher, carrying the \
             tenant and registry it baked in). It is refusing here because it could not FIND \
             that shortcut — not because the edition cannot be launched. If the user has one, \
             point the bridge at it with SWX_SOLIDWORKS_SHORTCUT=<path to the .lnk> and retry; \
             swx_status reports `solidworks.shortcut` and `solidworks.launchRoute` so you can see \
             what it found. (Classic desktop SOLIDWORKS installs launch over COM and are not \
             affected.)",
        );
    }
    let job = ctx.jobs.start("app_start", "Starting SOLIDWORKS (cold start can take 30-90s)", 75);
    let started = std::time::Instant::now();
    match ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": true}), 280) {
        Err(e) => sidecar_error(e),
        Ok(r) => {
            crate::ledger::record_time("swx_start", started.elapsed().as_secs_f64());
            crate::ledger::record("solidworks_launched", json!({"edition": detection.edition()}));
            ok_merge(
                r,
                json!({
                    "edition": detection.edition(),
                    "windowEtiquette": "background",
                    "progress": job.finished(),
                    "_hint": "SOLIDWORKS is up (COM-attached). It opens on the user's desktop; \
                              the bridge does not foreground it. swx_open or swx_new_document \
                              next, or swx_docs to see what is already loaded.",
                }),
            )
        }
    }
}

/// `swx_start` on a machine whose SOLIDWORKS is started by the Platform's own
/// shortcut — which is every SOLIDWORKS Connected machine.
///
/// GATED AS A SESSION-STARTING ACTION: a `reason` and nothing more. It runs an
/// external process on the user's desktop, so it is recorded for them; but it
/// creates no document, writes no file and destroys no work, so it does not go
/// near the write choke point. That asymmetry with `swx_stop` — which is always
/// gated — is deliberate and it is the honest one: starting SOLIDWORKS is undone
/// by closing it, whereas stopping it was, until this route existed, not undoable
/// by this bridge at all.
///
/// THIS VERB NO LONGER INVOKES THE LAUNCHER ITSELF. It hands the work to
/// [`crate::launch`], which invokes ONE route, watches the launcher's own log
/// for `Run Begin`, cleans up after a stall and tries the next route — and, most
/// importantly, is the single place a launch can start from. Four launcher
/// chains accumulated on Drew's desktop on 2026-09-04 because this verb's own
/// hint promised idempotence ("call swx_start again, which ATTACHES rather than
/// launching a second copy") that the code did not implement. It does now, and
/// the promise is kept by a persisted attempt rather than by a sentence.
fn start_via_shortcut(
    ctx: &Ctx,
    args: &Value,
    detection: &crate::detect::Detection,
    shortcut: &str,
) -> Value {
    let os = crate::launchos::os();

    // ── 1. IS ONE ALREADY IN FLIGHT? ───────────────────────────────────────
    // Before anything else, including the reason gate: a caller polling with
    // `swx_start` (which this bridge's own hint tells them to do) must never be
    // refused for a missing reason and must never start a second launcher.
    if let Some(a) = crate::launch::in_flight(os.as_ref()) {
        return still_launching(&a, os.as_ref(), detection, "an attempt this bridge started is still running");
    }

    // ── 2. IS SOLIDWORKS ALREADY UP BUT UNREACHABLE? ───────────────────────
    // We got here because the attach in `swx_start` failed. If sldworks.exe is
    // nevertheless running, LAUNCHING AGAIN IS THE WRONG MOVE — it would put a
    // second SOLIDWORKS on the user's desktop. Observed live on 2026-09-03: the
    // process was alive at 17.4 GB of private memory while COM attach failed.
    if crate::launch::sldworks_running(os.as_ref()) == Some(true) {
        return running_but_unreachable(ctx, os.as_ref(), detection);
    }

    // ── 3. THE REASON GATE ─────────────────────────────────────────────────
    let Some(reason) = arg_str(args, "reason") else {
        return err_code(
            "reason_required",
            "swx_start needs reason:\"why\" to launch SOLIDWORKS on the user's desktop",
            "This launches an application on somebody's machine — a window appears, and on a \
             Connected edition it signs in to their 3DEXPERIENCE tenant. The reason is written \
             for them: it appears in their Activity Log next to your AI thread name. Say what \
             you are about to do with SOLIDWORKS, in one line. Nothing else is required — \
             starting the application creates nothing and destroys nothing.",
        );
    };

    // ── 4. BEGIN. Exactly one attempt can win this. ────────────────────────
    let caller = crate::caller::current().map(|c| c.thread).unwrap_or_default();
    let job = ctx.jobs.start(
        "app_start",
        "Starting SOLIDWORKS via the 3DEXPERIENCE launcher (cold start runs 1-3 minutes)",
        180,
    );
    let started = std::time::Instant::now();
    let channel = crate::launch::Channel { com: std::sync::Arc::clone(&ctx.com) };
    let attempt = match crate::launch::begin(channel, shortcut, reason, &caller) {
        Ok(a) => a,
        // Lost the race with a simultaneous swx_start. That call owns the
        // launch; this one reports it and launches nothing.
        Err(crate::launch::BeginRefusal::InFlight(a)) => {
            return still_launching(&a, os.as_ref(), detection, "another call started this launch first")
        }
        // BACKOFF. The last attempt failed, and a retry that immediately swept
        // all four routes again is how four launcher chains ended up on
        // somebody's desktop. Say so, and say for how long.
        Err(crate::launch::BeginRefusal::Backoff { seconds, attempt }) => {
            let mut out = err_code(
                "host_app_launch_backoff",
                format!(
                    "the last launch attempt failed; this bridge waits {seconds}s before trying \
                     again rather than starting more launcher chains"
                ),
                "A retry that re-sweeps every route starts a fresh CATSTART/SWXDesktopLauncher \
                 chain per route, and that is how four of them accumulated on a user's desktop. \
                 The wait grows with each consecutive failure, and the next attempt tries ONE \
                 route rather than the whole plan. Meanwhile the honest move is the human one: \
                 ASK THE USER to start SOLIDWORKS from their Start Menu — an interactive launch \
                 works when these do not. swx_status.launch shows what has been tried.",
            );
            if let Value::Object(map) = &mut out {
                map.insert("backoffSeconds".into(), json!(seconds));
                map.insert("launch".into(), crate::launch::report(os.as_ref()));
                if let Some(a) = attempt {
                    map.insert("lastOutcome".into(), json!(a.outcome));
                    map.insert("lastError".into(), json!(a.error));
                }
            }
            return out;
        }
    };

    // ── 5. WAIT, BOUNDED. ──────────────────────────────────────────────────
    // The supervisor keeps running after this verb returns, so the budget here
    // is a COURTESY, not the launch's lifetime. It stays short by default:
    // a verb that outlives the HTTP request that called it just looks like a
    // hang. `waitSeconds` lets a caller with a longer budget block instead.
    let budget = std::time::Duration::from_secs(
        args.get("waitSeconds").and_then(|v| v.as_u64()).unwrap_or(20).min(240),
    );
    let mut current = attempt;
    while started.elapsed() < budget {
        std::thread::sleep(std::time::Duration::from_millis(400));
        current = crate::launch::attempt().unwrap_or(current);
        if current.finished || current.stage == crate::launch::Stage::Attached {
            break;
        }
    }
    let attached = current.stage == crate::launch::Stage::Attached;
    if attached {
        crate::ledger::record_time("swx_start", started.elapsed().as_secs_f64());
        crate::launch::mark_attached();
        let pid = ctx
            .call("app_state", json!({}), 20)
            .ok()
            .and_then(|r| r.get("processId").and_then(|v| v.as_u64()))
            .map(|p| p as u32);
        crate::launch::attribute_on_attach(os.as_ref(), pid);
    }
    let version = if attached {
        ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": false}), 30)
            .ok()
            .and_then(|r| r.get("version").cloned())
            .unwrap_or(Value::Null)
    } else {
        Value::Null
    };
    ok(json!({
        "started": true,
        "attached": attached,
        // NOT "started:false". The launch WAS made — the application is coming
        // up on the user's desktop. Reporting a failure here would invite a
        // caller to launch it again, which is the one thing that must not happen.
        "stillLaunching": !attached,
        "wasLaunched": true,
        "version": version,
        "edition": detection.edition(),
        "launchRoute": "platformShortcut",
        "route": current.route,
        "stage": current.stage.name(),
        "shortcut": shortcut,
        "elapsedSeconds": started.elapsed().as_secs(),
        "launch": crate::launch::report(os.as_ref()),
        "windowEtiquette": "background",
        "progress": job.finished(),
        "_hint": if attached {
            "SOLIDWORKS started via the 3DEXPERIENCE launcher and is COM-attached. \
             swx_new_document or swx_open next.".to_string()
        } else {
            format!(
                "THE LAUNCH IS IN FLIGHT, not failed. Stage: {}. {} A supervisor inside this \
                 bridge is watching the launcher's own log; if this route stalls before \
                 'Run Begin' it will end ONLY the chain it started and try the next route by \
                 itself, so there is nothing for you to retry. Poll swx_status (its `launch` \
                 block carries the live stage) or call swx_start again — calling it again during \
                 a launch NEVER starts a second one, it reports this same attempt. Do NOT invoke \
                 the shortcut any other way in the meantime: two launches means two sign-in \
                 windows on somebody's desktop. waitSeconds:<n> (up to 240) blocks here instead \
                 of polling.",
                current.stage.name(),
                current.stage.advice(),
            )
        },
    }))
}

/// The answer to `swx_start` while a launch this bridge started is still
/// running. `success:true` and `stillLaunching:true` — nothing failed, and the
/// one thing a caller must not do is launch again.
fn still_launching(
    a: &crate::launch::Attempt,
    os: &dyn crate::launchos::LaunchOs,
    detection: &crate::detect::Detection,
    why: &str,
) -> Value {
    ok(json!({
        "started": true,
        "attached": false,
        "stillLaunching": true,
        "wasLaunched": false,
        "launchedNow": false,
        "reasonNotLaunched": why,
        "stage": a.stage.name(),
        "route": a.route,
        "ageSeconds": a.age_secs(),
        "edition": detection.edition(),
        "launchRoute": "platformShortcut",
        "shortcut": a.shortcut,
        "launch": crate::launch::report(os),
        "windowEtiquette": "background",
        "_hint": format!(
            "NOTHING WAS LAUNCHED BY THIS CALL, deliberately: {why} ({}s ago, stage '{}'). {} \
             Poll swx_status — its `launch` block carries the live stage, the route being tried \
             and every route already tried — or call swx_start again, which will keep answering \
             like this until SOLIDWORKS attaches or every route is exhausted. Do NOT invoke the \
             shortcut any other way in the meantime: two launches means two sign-in windows on \
             somebody's desktop.",
            a.age_secs(),
            a.stage.name(),
            a.stage.advice(),
        ),
    }))
}

/// SOLIDWORKS IS RUNNING AND WE CANNOT ATTACH. Three genuinely different
/// situations, and the bridge must not blur them into "not running" — which is
/// what it said on 2026-09-03, when the honest answer was "it is alive and out
/// of memory".
fn running_but_unreachable(
    ctx: &Ctx,
    os: &dyn crate::launchos::LaunchOs,
    detection: &crate::detect::Detection,
) -> Value {
    let status = ctx.call("status", json!({}), 10);
    let open_docs = status.as_ref().ok().and_then(|r| r.get("openDocs").and_then(|v| v.as_u64()));
    let com_pid = status
        .as_ref()
        .ok()
        .and_then(|r| r.get("processId").and_then(|v| v.as_u64()))
        .map(|p| p as u32);
    // Did a COM call EXCEED ITS BUDGET, or merely fail? Only the first is the
    // COM half of the hang test.
    let com_timed_out = match &status {
        Ok(_) => Some(false),
        // An unrecognised com failure is UNKNOWN — see swx_status.
        Err(e) if e.timed_out => Some(true),
        Err(_) => None,
    };
    let health = crate::launch::host_app_health(os, open_docs, com_timed_out, com_pid);
    let hung = health.get("hung").and_then(|v| v.as_bool()).unwrap_or(false);
    let exhausted = health.get("exhausted").and_then(|v| v.as_str()).map(String::from);
    let mut out = if hung {
        err_code(
            "host_app_hung",
            "SOLIDWORKS is running but not responding: Windows reports its window is hung and a \
             COM call from this bridge timed out",
            crate::launch::HUNG_HINT,
        )
    } else if let Some(why) = exhausted {
        err_code(
            "host_app_exhausted",
            format!("SOLIDWORKS is running but out of room — {why}"),
            crate::launch::EXHAUSTED_HINT,
        )
    } else {
        err_code(
            "host_app_com_unreachable",
            "SOLIDWORKS is running, but it is not answering COM and this bridge will not start a \
             second copy",
            "sldworks.exe IS on the user's machine — so launching again would put a SECOND \
             SOLIDWORKS on their desktop, which is never the right move. The usual cause on a \
             Connected edition is a 3DEXPERIENCE sign-in or licence window waiting for an answer \
             only the user can give: ask them to look at their screen. swx_state (with a \
             background screenshot) shows what it is displaying, and swx_status.solidworks \
             reports whether the window is responding at all.",
        )
    };
    if let Value::Object(map) = &mut out {
        map.insert("solidworksProcess".into(), health);
        map.insert("edition".into(), json!(detection.edition()));
        map.insert("launch".into(), crate::launch::report(os));
        map.insert("windowEtiquette".into(), json!("background"));
    }
    out
}

/// EVERY PRECONDITION FACT, measured in one pass, with no COM call that could
/// block on a wedged session doing the measuring of whether it is wedged.
///
/// This exists as a struct because the sweep has to be REPEATABLE: the ladder
/// re-runs it before every rung. It was originally swept once, before the loop,
/// which meant "verified zero documents open" held for rung (a) only — a
/// document opened while rung (a) settled was still open when rung (b) fired
/// `RunCommand(swCommands_App_Exit)` at the session, and the reasoning that a
/// save prompt at a verified zero "should be impossible" depended on the promise
/// this struct now keeps.
struct StopEvidence {
    open_docs: Option<u64>,
    listed_docs: Option<u64>,
    docs_truncated: bool,
    unsaved: Option<u64>,
    busy: Option<bool>,
    identity: Option<crate::quit::Identity>,
    dialog: Option<crate::quit::BlockingDialog>,
    /// Windows the census judged NOT blocking — notifications, reported with
    /// their evidence and never answered (0.9.4).
    notifications: Vec<crate::quit::BlockingDialog>,
    /// Every blocking dialog after the first (0.11.1): reported, never dropped.
    more_dialogs: Vec<crate::quit::BlockingDialog>,
    /// The pids the dialog census actually covered — SOLIDWORKS plus its CEF
    /// subprocesses, which is where a 3DEXPERIENCE sign-in window lives.
    dialog_pids: Vec<u32>,
    census_readable: bool,
    responding: Option<bool>,
    host_busy: bool,
    launch_busy: bool,
    census: Result<Vec<crate::launchos::ProcInfo>, String>,
}

impl StopEvidence {
    fn sweep(ctx: &Ctx, os: &dyn crate::launchos::LaunchOs) -> Self {
        use crate::quit;
        let state = ctx.call("app_state", json!({}), 30);
        let get = |k: &str| state.as_ref().ok().and_then(|s| s.get(k).cloned());
        let open_docs = get("documentCount").and_then(|v| v.as_u64());
        let docs_truncated =
            get("documentsTruncated").and_then(|v| v.as_bool()).unwrap_or(false);
        // The ENUMERATION's own length, for the count cross-check. Above the cap
        // it is deliberately empty and `documentsTruncated` refuses first, so it
        // is only compared when the host claims to have listed everything.
        let listed_docs = if docs_truncated {
            None
        } else {
            get("documents").and_then(|v| v.as_array().map(|a| a.len() as u64))
        };
        let unsaved = get("unsavedDocuments").and_then(|v| v.as_u64());
        let busy = get("busy").and_then(|v| v.as_bool());
        let com_pid = get("processId").and_then(|v| v.as_u64()).map(|p| p as u32);
        let com_hwnd = get("mainHwnd").and_then(|v| v.as_i64());

        // P7: pid + creation time + main window, from the census (no COM).
        let census = os.processes();
        let identity = com_pid.and_then(|pid| {
            let created = census
                .as_ref()
                .ok()
                .and_then(|c| c.iter().find(|p| p.pid == pid).and_then(|p| p.created_at))?;
            Some(quit::Identity {
                pid,
                created_at: created,
                main_hwnd: com_hwnd.or_else(|| os.main_window_of_pid(pid)),
            })
        });
        // P3: the window census, readable with no ab and no COM — over EVERY pid
        // a modal could belong to, not just sldworks.exe.
        let mut dialog_pids: Vec<u32> = Vec::new();
        let mut census_readable = false;
        let mut dialog = None;
        let mut notifications: Vec<crate::quit::BlockingDialog> = Vec::new();
        let mut more_dialogs: Vec<crate::quit::BlockingDialog> = Vec::new();
        if let Some(id) = identity.as_ref() {
            if let Some(pids) = quit::dialog_census_pids(census.as_ref(), id) {
                let mut all_read = true;
                let mut windows: Vec<crate::launchos::WindowInfo> = Vec::new();
                for pid in &pids {
                    match os.windows_of_pid(*pid) {
                        Ok(w) => windows.extend(w),
                        // ONE unreadable pid makes the whole answer UNKNOWN: the
                        // dialog we cannot see is exactly the one that matters.
                        Err(_) => all_read = false,
                    }
                }
                dialog_pids = pids;
                census_readable = all_read;
                // READ WHAT IT SAYS, here too. 0.8.4 gave the SWEEP the dialog's
                // own words and left the LADDER reporting a title — and the
                // ladder is where a stuck session gets diagnosed. A title cannot
                // tell "your licence expired" from "the licence server is
                // unreachable and this session ends after five more tries".
                // Reading is not answering; nothing here presses anything.
                // JUDGED BY MEASURED MODALITY (0.9.4): a notification is reported,
                // never answered, and does not refuse the quit.
                let judged = quit::dialog_census(&windows, os);
                dialog = judged.blocking;
                notifications = judged.notifications;
                more_dialogs = judged.more_blocking;
            }
        }
        // P5: responding, without COM.
        let responding = identity
            .as_ref()
            .and_then(|i| i.main_hwnd)
            .and_then(|h| os.window_responding(h));
        Self {
            open_docs,
            listed_docs,
            docs_truncated,
            unsaved,
            busy,
            identity,
            dialog,
            notifications,
            more_dialogs,
            dialog_pids,
            census_readable,
            responding,
            host_busy: ctx.com.snapshot().busy,
            launch_busy: crate::launch::in_flight(os).is_some(),
            census,
        }
    }

    fn check(&self, force: bool) -> Result<(), crate::quit::Refusal> {
        crate::quit::check_preconditions(
            self.open_docs,
            self.listed_docs,
            self.docs_truncated,
            self.unsaved,
            self.dialog.as_ref(),
            self.census_readable,
            self.busy,
            self.host_busy,
            self.responding,
            self.identity.as_ref(),
            self.launch_busy,
            force,
        )
    }

    fn to_json(&self, force: bool) -> Value {
        json!({
            "openDocuments": self.open_docs,
            "listedDocuments": self.listed_docs,
            "documentsTruncated": self.docs_truncated,
            "unsavedDocuments": self.unsaved,
            // WHICH dialog, whose, how old, and whether a bridge verb raised it
            // (0.11.1) — and whether the bridge may cancel it.
            "dialog": self.dialog.as_ref().map(|d| d.report(crate::launchos::os().as_ref())),
            "moreBlockingDialogs": self.more_dialogs.iter().map(|d| d.report(crate::launchos::os().as_ref())).collect::<Vec<_>>(),
            "notifications": self.notifications.iter().map(|d| d.to_json()).collect::<Vec<_>>(),
            "dialogCensusReadable": self.census_readable,
            "dialogCensusPids": self.dialog_pids,
            "busy": self.busy,
            "hostBusy": self.host_busy,
            "responding": self.responding,
            "identity": self.identity.as_ref().map(|i| i.to_json()),
            "launchInFlight": self.launch_busy,
            "force": force,
        })
    }
}

/// The caller-facing hint for each refusal code, so the front gate and a
/// mid-ladder refusal say the same thing about the same fact.
fn stop_refusal_hint(code: &str) -> &'static str {
    match code {
        "open_documents_present" =>
            "This bridge quits ONLY a session with no documents open, and `force` does not waive \
             that — force waives the UNSAVED question only. Close the documents first (they are \
             the user's; ask), or leave SOLIDWORKS running.",
        "stop_blocked_by_save_prompt" =>
            "A save prompt with a VERIFIED zero open documents should be impossible, so this is a \
             bug to report, not a dialog to dismiss: the document count was wrong. This bridge \
             never answers a save prompt — the buttons offer to write the user's work to This PC \
             or to 3DEXPERIENCE, and that is their decision. Ask them to answer it.",
        "stop_blocked_by_dialog" =>
            "SOLIDWORKS is asking something. This bridge never answers a dialog — closing a stuck \
             modal through the window manager took the whole application down on 2026-09-02. \
             preconditions.dialog names it (hwnd, class, owner, ageSeconds, attribution). ONE \
             exception: the standard file dialog with an EMPTY file name that a bridge verb \
             provably raised (bridgeMayCancel:true) — swx_dismiss_blocking_dialogs {close:true, \
             confirm:true, reason} cancels it, a no-op. Otherwise the user cancels it: swx_state \
             takes a background screenshot to show them.",
        "stop_preconditions_unknown" =>
            "Nothing was attempted. Every precondition must be MEASURED, and an unanswerable one \
             is not permission — quitting on a guess is how a session ends up half-closed with the \
             user's work in it. Retry once the reading is available (swx_status reports each of \
             these), or leave it running.",
        "identity_mismatch" =>
            "The launch record and the live COM session name DIFFERENT SOLIDWORKS processes, so \
             this bridge cannot say the session it would end is the one it launched. Nothing was \
             attempted. swx_status reports both; swx_start re-establishes which session it is \
             talking to, and a bridge restart clears a stale claim.",
        "stop_identity_changed" =>
            "The SOLIDWORKS this bridge is attached to is NOT the one the ladder started against \
             — a different pid, or the same pid with a different creation time. Nothing further \
             was sent: every rung's success measurement is 'this exact process is gone', and it \
             would now be measuring the wrong process. Call swx_status, then start over.",
        _ =>
            "Nothing was attempted. swx_status reports the open-document count, whether SOLIDWORKS \
             is responding, and whether this bridge's com host is busy.",
    }
}

/// `swx_stop` v2 — THE MEASURED LADDER (docs/QUIT-INVESTIGATION.md §5).
///
/// Preconditions verified by evidence, every COM reference released before any
/// rung, success measured by a PROCESS CENSUS rather than a COM call, and nothing
/// disowned until the process is verifiably gone. Never a kill. Never answers a
/// dialog. See `crate::quit` for the three laws and the refcount reasoning.
/// `swx_com_respawn` (0.10.6) — THE REFERENCE QUESTION, SETTLED BY MEASURING.
///
/// Measured 2026-10-14: each scratch document created and closed left
/// SOLIDWORKS ~83 MB and ~125 handles heavier, with GetDocumentCount back at
/// 0. If the BRIDGE pins something (a COM reference its host never released),
/// respawning the host releases it and the handle count drops; if not, nothing
/// changes. This respawns ONLY the bridge's own com host child — never
/// SOLIDWORKS, which it does not call at all — and refuses while a verb holds
/// the host. Ledgered.
fn swx_com_respawn(ctx: &Ctx, args: &Value) -> Value {
    let Some(reason) = arg_str(args, "reason").map(str::trim).filter(|r| r.chars().count() >= 8) else {
        return err_code(
            "reason_required",
            "swx_com_respawn needs reason:\"why\" (8+ characters)",
            "It restarts the bridge's own COM host process; the reason goes into the ledger beside the \
             old and new host pids. SOLIDWORKS is not touched.",
        );
    };
    let snap = ctx.com.snapshot();
    if snap.busy {
        return err_code(
            "com_host_busy",
            format!(
                "a verb is in flight on the com host ({}); swx_com_respawn never pulls the host out from under a COM call",
                snap.in_flight.unwrap_or_else(|| "unknown".into())
            ),
            "Wait for it to finish (swx_progress shows it), then call again. Nothing was changed.",
        );
    }
    match ctx.com.respawn() {
        Err(why) => err_code(
            "com_host_busy",
            format!("the com host could not be respawned: {why}"),
            "Nothing was changed. swx_progress shows what is in flight.",
        ),
        Ok(rep) => {
            crate::ledger::record(
                "com_host_respawn",
                json!({"by": "swx_com_respawn", "reason": reason, "oldHostPid": rep.old_pid,
                       "newHostPid": rep.new_pid, "gracefulExit": rep.graceful_exit}),
            );
            ok(json!({
                "respawned": true,
                "oldHostPid": rep.old_pid,
                "newHostPid": rep.new_pid,
                "gracefulExit": rep.graceful_exit,
                "solidworksTouched": false,
                "windowEtiquette": "background",
                "_hint": "The bridge's COM host was replaced. The old one left through its own loop, so its \
                          backend was dropped and every COM reference it held was released by Release() — \
                          not left to COM's ~6-minute ping rundown (gracefulExit:false means it had to be \
                          killed, and then the rundown applies). SOLIDWORKS was not called: the new host \
                          attaches on the next verb. Compare SOLIDWORKS's handle count (swx_status.memory) \
                          before and after: a drop means the bridge was pinning something.",
            }))
        }
    }
}

fn swx_stop(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    use crate::quit;
    let os = crate::launchos::os();
    let force = arg_bool(args, "force", false);
    let started = std::time::Instant::now();

    // ── THE LADDER PLAN. Pinned at rung "a" by default: rungs (b)-(f) send
    //    window messages and run VBA inside somebody's CAD session and not one
    //    of them has been observed on a real machine.
    // ── DREW'S STANDING AUTHORIZATION, 2026-09-11. `userAuthorized:true` says the
    //    human has already answered the question this verb used to refuse for:
    //    whether the bridge may end their SOLIDWORKS. It changes exactly two
    //    things and nothing else.
    //
    //      * the ABSENCE of a launch attribution stops being a blocker — a
    //        session the bridge did not launch may still be quit, because the
    //        user said so. (The identity MISMATCH guard stays: a claim that names
    //        a different process still refuses, because that is not consent, it
    //        is a bookkeeping error.)
    //      * rungs (c) and (d) become available, since COM can be wedged behind a
    //        licence dialog on a locked desktop and those two need no COM at all.
    //
    //    It does NOT waive: the zero-open-documents rule, the dialog rule, the
    //    busy rule, rung (f) without allowRestartManager, or the law that nothing
    //    here ever kills anything.
    let user_authorized = arg_bool(args, "userAuthorized", false);
    let max_rung = args
        .get("maxRung")
        .and_then(|v| v.as_str())
        .unwrap_or(if user_authorized { "a" } else { quit::DEFAULT_MAX_RUNG })
        .to_string();
    if !user_authorized && matches!(max_rung.as_str(), "c" | "d") {
        return err_code(
            "rung_needs_user_authorization",
            format!(
                "rung '{max_rung}' sends a window message into the user's SOLIDWORKS, which needs                  their say-so: pass userAuthorized:true with a reason"
            ),
            "The window-message rungs act on the application's own frame rather than through COM.              They are available when the user has authorized the bridge to close SOLIDWORKS —              `userAuthorized:true` — which is also what makes them useful, because a session wedged              behind a licence dialog cannot be reached through COM at all.",
        );
    }
    let plan = quit::rungs_up_to(&max_rung);
    if plan.is_empty() {
        return err_code(
            "unknown_rung",
            format!("maxRung:\"{max_rung}\" is not a rung of the ladder"),
            "Rungs are \"a\" (ExitApp) through \"f\" (Restart Manager, opt-in). The default is \
             \"a\" until the live quit gate has run; see docs/QUIT-INVESTIGATION.md §5.3.",
        );
    }
    let allow_rm = arg_bool(args, "allowRestartManager", false);

    // ── IS THERE ANYTHING TO STOP? A clear answer for the commonest case, so
    //    "nothing is running" never arrives dressed as an unmeasurable
    //    precondition. The com host's own word, preserved.
    if let Ok(st) = ctx.call("status", json!({}), 20) {
        if st.get("running").and_then(|v| v.as_bool()) == Some(false) {
            return ok_merge(
                json!({
                    "stopped": false,
                    "reason": "not running",
                    "success": false,
                    "errorCode": "host_app_not_running",
                    "error": "SOLIDWORKS is not running, so there is nothing to stop",
                    "rungsTried": [],
                }),
                json!({
                    "windowEtiquette": "background",
                    "_hint": "Nothing was attempted and nothing needed to be: the com host reports \
                              no attached SOLIDWORKS. swx_status confirms it. If a SOLIDWORKS \
                              window IS on the user's screen, this bridge cannot see it — that is \
                              an ATTACH problem, not a quit problem, and swx_start reports which.",
                    "_reportIssues": report_issues(),
                }),
            );
        }
    }

    // ── PRECONDITION EVIDENCE, measured now, never inherited from the caller,
    //    and re-measured before EVERY rung further down.
    let ev = StopEvidence::sweep(ctx, os.as_ref());
    let census0 = ev.census.clone();
    // ── THE LAUNCH'S CLAIM MUST BE ABOUT THE SESSION COM ANSWERS FOR.
    //    Re-swept with everything else below, because this verb re-measures
    //    before every rung and an attribution that was true at rung (a) is not
    //    automatically true at rung (b).
    if let Err(refusal) =
        quit::identity_matches_attribution(crate::launch::attributed_solidworks(), ev.identity.as_ref())
            .and_then(|_| ev.check(force))
    {
        // A REFUSAL IS RECORDED. v1 wrote no ledger line at its front gate, so
        // "we tried to quit and were told no" was invisible afterwards.
        crate::ledger::record(
            "solidworks_quit_refused",
            json!({"code": refusal.code(), "why": refusal.message(), "maxRung": max_rung}),
        );
        let mut out =
            err_code(refusal.code(), refusal.message(), stop_refusal_hint(refusal.code()));
        if let Value::Object(m) = &mut out {
            m.insert("preconditions".into(), ev.to_json(force));
            m.insert("rungsTried".into(), json!([]));
            m.insert("windowEtiquette".into(), json!("background"));
        }
        return out;
    }
    let id = ev.identity.clone().expect("checked by the precondition sweep");

    // ── P6: the process-level opt-in, a confirm and a human reason. Unchanged.
    let grant = match crate::writes::authorize_in(
        ctx,
        args,
        "swx_stop",
        crate::writes::Kind::EndSession,
        None,
        None,
    ) {
        Ok(g) => g,
        Err(e) => {
            crate::ledger::record(
                "solidworks_quit_refused",
                json!({"code": "user_writes_disabled", "why": "the write opt-in was not granted"}),
            );
            return e;
        }
    };

    let job = ctx.jobs.start("app_stop", "Asking SOLIDWORKS to quit (measured ladder)", 60);
    let census_before = census0.as_ref().ok().cloned().unwrap_or_default();
    if user_authorized {
        crate::ledger::record(
            "solidworks_quit_authorized",
            json!({
                "authority": "quit under Drew's standing authorization (2026-09-11)",
                "reason": arg_str(args, "reason"),
                "maxRung": max_rung,
            }),
        );
    }
    crate::ledger::record(
        "solidworks_quit_started",
        json!({
            "identity": id.to_json(),
            "plan": plan,
            "maxRung": max_rung,
            "allowRestartManager": allow_rm,
            "reason": arg_str(args, "reason"),
        }),
    );

    let mut tries: Vec<quit::RungTry> = Vec::new();
    let mut winning: Option<&'static str> = None;
    let mut blocked: Option<quit::BlockingDialog> = None;

    // DURING STARTUP (adversary pass 42): the dispatch gate let this quit through
    // only because the user authorized it and the plan reaches a window-message
    // rung. The COM rungs are not sent to a SOLIDWORKS that has not finished
    // starting; (c) and (d) act on the main frame and need neither.
    let startup_not_verified = arg_bool(args, "_startupNotVerified", false);
    for rung in plan.iter() {
        if startup_not_verified && matches!(*rung, "a" | "b" | "e") {
            tries.push(quit::RungTry {
                rung,
                action: quit::rung_action(rung),
                result: quit::RungResult::Skipped,
                process_gone: None,
                elapsed_sec: 0.0,
                new_windows: 0,
                dialog: None,
                children_alive: Vec::new(),
                survivors_alive: Vec::new(),
                unjudged_alive: Vec::new(),
                census_readable: false,
                detail: Some(
                    "SOLIDWORKS has not finished starting (or the start cannot be verified), so \
                     this COM rung was not sent; the window-message rungs need no COM"
                        .into(),
                ),
                host_error: None,
                primed: false,
            });
            continue;
        }
        // Rung (f) is opt-in: ENDSESSION_CLOSEAPP means "save automatically
        // without prompting", so it is the one rung whose failure mode is a
        // silent write rather than a dialog we can stop on.
        if *rung == "f" && !allow_rm {
            tries.push(quit::RungTry {
                rung,
                action: quit::rung_action(rung),
                result: quit::RungResult::Skipped,
                process_gone: None,
                elapsed_sec: 0.0,
                new_windows: 0,
                dialog: None,
                children_alive: Vec::new(),
                survivors_alive: Vec::new(),
                unjudged_alive: Vec::new(),
                // Nothing was measured because nothing was sent.
                census_readable: false,
                detail: Some(
                    "the Restart Manager rung needs allowRestartManager:true — its shutdown \
                     request tells applications to save WITHOUT prompting, so it is never taken \
                     implicitly"
                        .into(),
                ),
                host_error: None,
                primed: false,
            });
            continue;
        }
        // ── RE-MEASURE EVERY PRECONDITION, BEFORE EVERY RUNG. Not once before
        //    the loop: a rung takes seconds to settle, and a document opened in
        //    that gap was still open when the NEXT rung fired at the session.
        //    `force` is passed unchanged, so it still waives the unsaved question
        //    only.
        if !tries.is_empty() {
            let now = StopEvidence::sweep(ctx, os.as_ref());
            // The same process, still? A different pid — or the same pid with a
            // different creation time — means the ladder would be measuring
            // something it never acted on.
            let same = now.identity.as_ref().map(|i| i.pid == id.pid && i.created_at == id.created_at);
            let claim = crate::launch::attributed_solidworks();
            let refusal = match quit::identity_matches_attribution(claim, now.identity.as_ref())
                .and_then(|_| now.check(force))
            {
                Err(r) => Some(r),
                Ok(()) if same == Some(false) => Some(quit::Refusal::Known {
                    code: "stop_identity_changed",
                    message: "the SOLIDWORKS this bridge is attached to is no longer the process \
                              the ladder started against"
                        .into(),
                }),
                Ok(()) => None,
            };
            if let Some(refusal) = refusal {
                crate::ledger::record(
                    "solidworks_quit_refused",
                    json!({
                        "code": refusal.code(),
                        "why": refusal.message(),
                        "maxRung": max_rung,
                        "afterRungs": tries.len(),
                        "midLadder": true,
                    }),
                );
                let mut out = err_code(
                    refusal.code(),
                    format!("{} (measured after rung '{}')", refusal.message(), tries.last().map(|t| t.rung).unwrap_or("?")),
                    stop_refusal_hint(refusal.code()),
                );
                if let Value::Object(m) = &mut out {
                    m.insert("preconditions".into(), now.to_json(force));
                    m.insert(
                        "rungsTried".into(),
                        json!(tries.iter().map(|t| t.to_json()).collect::<Vec<_>>()),
                    );
                    m.insert("stopped".into(), json!(false));
                    m.insert("stillRunning".into(), json!(quit::process_gone(now.census.as_ref(), &id).map(|g| !g)));
                    m.insert("identity".into(), id.to_json());
                    m.insert("maxRung".into(), json!(max_rung));
                    m.insert("windowEtiquette".into(), json!("background"));
                    m.insert("refusedMidLadder".into(), json!(true));
                }
                let _ = job.finished();
                return out;
            }
        }
        let mut attempt = run_rung(ctx, os.as_ref(), &id, rung);
        // ── PRIME A NEVER-HAD-A-DOCUMENT SESSION, THEN ASK AGAIN. MEASURED
        //    2026-10-01 (pid 20144): rung (a) was declined three times on a
        //    session that had never held a document, and quit in 3.4 s once one
        //    scratch part had been created and closed in it. So on rung (a) only,
        //    under the user's authorization, on an EMPTY session with no dialog,
        //    whose history says it never held a document (or cannot say): create
        //    one bridge-owned scratch part, close it, and ExitApp once more.
        if *rung == "a"
            && attempt.result == quit::RungResult::Declined
            && attempt.dialog.is_none()
            && user_authorized
        {
            let presence_overridden = arg_bool(args, "userPresentOverride", false);
            match prime_session(ctx, os.as_ref(), &id, presence_overridden) {
                Ok(prime) => {
                    crate::ledger::record(
                        "solidworks_quit_rung",
                        json!({"rung": rung, "result": attempt.result.name(), "processGone": attempt.process_gone}),
                    );
                    tries.push(attempt);
                    attempt = run_rung(ctx, os.as_ref(), &id, rung);
                    attempt.primed = true;
                    attempt.detail = Some(format!(
                        "{} — after PRIMING the session (one bridge-owned scratch part created and \
                         closed: {})",
                        attempt.detail.clone().unwrap_or_default(),
                        prime
                    ));
                }
                Err(why) => {
                    attempt.detail = Some(format!(
                        "{} — not primed: {why}",
                        attempt.detail.clone().unwrap_or_default()
                    ));
                }
            }
        }
        let result = attempt.result;
        let new_dialog = attempt.dialog.clone();
        let gone = attempt.process_gone;
        tries.push(attempt);
        crate::ledger::record(
            "solidworks_quit_rung",
            json!({"rung": rung, "result": result.name(), "processGone": gone}),
        );
        if result == quit::RungResult::Quit {
            winning = Some(rung);
            break;
        }
        if result == quit::RungResult::DialogAppeared {
            blocked = new_dialog;
            break;
        }
        if result == quit::RungResult::UipiBlocked {
            break;
        }
    }

    let census_after = os.processes();
    let stopped = winning.is_some();
    // v1 shipped `stillRunning`, and it is the field a caller reads to answer
    // "is it gone?" without parsing the ladder. Keep it — MEASURED from the
    // final census, and null (never false) when the census could not be read,
    // because an unreadable census is not proof of an exit.
    let still_running = quit::process_gone(census_after.as_ref(), &id).map(|gone| !gone);
    // NOTHING IS DISOWNED UNTIL THE PROCESS IS VERIFIABLY GONE. v1 cleared the
    // registry before it verified, so a declined quit left the bridge unable to
    // touch its own documents.
    if stopped {
        ctx.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).clear();
        crate::owned::clear();
        crate::ledger::record(
            "solidworks_stopped",
            json!({"rung": winning, "authorized": grant.user_write, "identity": id.to_json()}),
        );
        // THE QUIT IS REMEMBERED (0.11.3): a start right after it waits for this
        // session's process tree to be gone, and the performance log's state now
        // — no SOLIDWORKS writing it — bounds what the NEXT session may claim.
        crate::perflog::record_baseline("quit", None);
        let _ = std::fs::write(
            crate::scratch::root().join("last-quit.json"),
            json!({"pid": id.pid, "createdAt": id.created_at, "atMs": crate::launchos::epoch_ms() as u64}).to_string(),
        );
    }
    let rungs_json: Vec<Value> = tries.iter().map(|t| t.to_json()).collect();
    let census_json = |c: &Result<Vec<crate::launchos::ProcInfo>, String>| -> Value {
        match c {
            Ok(v) => json!(v.iter().map(|p| p.to_json()).collect::<Vec<_>>()),
            Err(e) => json!({"error": e}),
        }
    };
    let body = json!({
        "stopped": stopped,
        "winningRung": winning,
        "rungsTried": rungs_json,
        "maxRung": max_rung,
        "elapsedSeconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0,
        "identity": id.to_json(),
        // The sweep that let the FIRST rung run. Each rung's own re-sweep is
        // what gated the rungs after it; a mid-ladder refusal returns its own.
        "preconditions": ev.to_json(force),
        "forceWaivedUnsavedOnly": force,
        "processCensusBefore": census_before.iter().map(|p| p.to_json()).collect::<Vec<_>>(),
        "processCensusAfter": census_json(&census_after),
        "expectedSurvivorsAreNotAFailure": quit::EXPECTED_SURVIVORS,
        "stillRunning": still_running,
        "blockingDialog": blocked.as_ref().map(|d| d.to_json()),
        "windowEtiquette": "background",
        "progress": job.finished(),
    });
    if stopped {
        return ok_merge(body, json!({
            "_hint": format!(
                "SOLIDWORKS quit on rung '{}' ({}), verified by process census — the pid and its \
                 creation time are gone from the machine, not merely absent from the COM running \
                 object table. Per-session children reaped; `expectedSurvivorsAlive` lists the \
                 platform daemons that survive a graceful exit BY DESIGN (ENOPLMCSAClient, \
                 EdmServerV6, 3DEXPERIENCELauncher*) — their presence is consent, not a failure, \
                 and nothing should ever kill them. On a Connected edition starting it again takes \
                 1-3 minutes through swx_start.",
                winning.unwrap_or("?"),
                quit::rung_action(winning.unwrap_or("a")),
            ),
        }));
    }
    let mut out = body;
    if let Value::Object(m) = &mut out {
        m.insert("success".into(), json!(false));
        m.insert(
            "errorCode".into(),
            json!(match blocked.as_ref() {
                Some(d) => d.error_code(),
                None => "host_app_stop_declined",
            }),
        );
        m.insert(
            "error".into(),
            json!(match blocked.as_ref() {
                Some(d) => format!(
                    "the quit stopped at a dialog SOLIDWORKS raised ({}): \"{}\" — this bridge \
                     never answers one",
                    d.kind, d.title
                ),
                None => format!(
                    "SOLIDWORKS did not quit; {} rung(s) were tried and measured",
                    tries.len()
                ),
            }),
        );
        m.insert(
            "_hint".into(),
            json!(match blocked.as_ref() {
                Some(d) => quit::blocked_hint(d),
                None => quit::declined_hint(tries.len(), &max_rung),
            }),
        );
        m.insert("_reportIssues".into(), json!(report_issues()));
    }
    out
}

fn swx_docs(ctx: &Ctx, _args: &Value) -> Value {
    gate!(ctx);
    match ctx.call("docs", json!({}), 30) {
        Err(e) => sidecar_error(e),
        // The com host already degraded above the cap and set `truncated`, so
        // this never blocks the full 30s on a huge count. When it truncated,
        // the host's own `_hint` (the drain guidance) is the right one and must
        // not be overwritten — ok_merge lets extras win, so pass none.
        Ok(r) => {
            if r.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false) {
                ok_merge(r, json!({}))
            } else {
                ok_merge(
                    r,
                    json!({
                        "_hint": "Pass any doc's `path` to the analysis verbs to target it \
                                  without activating it — no window changes, nothing steals \
                                  focus. `count` is authoritative (GetDocumentCount); if it is \
                                  ever much larger than the list, run swx_close_import_temps.",
                    }),
                )
            }
        }
    }
}

// ── verbs: documents ────────────────────────────────────────────────────────

/// ONE RUNG OF THE QUIT LADDER, sent and MEASURED: release every reference, ask,
/// release again, then poll the process census up to the rung's budget for the
/// two answers that end the ladder (gone, or a dialog). Never a COM call to
/// measure. The caller records it.
fn run_rung(
    ctx: &Ctx,
    os: &dyn crate::launchos::LaunchOs,
    id: &crate::quit::Identity,
    rung: &'static str,
) -> crate::quit::RungTry {
    use crate::quit;
    let rung_started = std::time::Instant::now();
    // RELEASE EVERY REFERENCE FIRST. Rung (a) also ends by releasing; this
    // makes rungs (b)-(f) START released, which is the ladder-wide invariant.
    let _ = ctx.call("release_com", json!({}), 20);
    let asked = ctx.call(
        "quit_rung",
        json!({
            "rung": rung,
            "mainHwnd": id.main_hwnd,
            "processId": id.pid,
            "processCreatedAt": id.created_at,
        }),
        quit::rung_budget_secs(rung) + 20,
    );
    let (host_word, detail, host_error) = match &asked {
        Ok(r) => (
            r.get("result").and_then(|v| v.as_str()).unwrap_or("").to_string(),
            r.get("detail").and_then(|v| v.as_str()).map(String::from),
            None,
        ),
        // A rung that WORKED tears the COM server down underneath the call
        // that asked for it, so a transport failure here is not a verdict —
        // the census below decides.
        Err(e) => (String::new(), None, Some(e.message.clone())),
    };
    // ── MEASURE, and never with a COM call. Release again first so nothing
    //    this process holds can keep the app alive while we ask.
    let _ = ctx.call("release_com", json!({}), 10);
    let refs = ctx
        .call("com_refs", json!({}), 10)
        .ok()
        .and_then(|r| r.get("outstanding").and_then(|v| v.as_u64()))
        .unwrap_or(0);
    // ── WAIT FOR THE EXIT, don't sample once for it. `ExitApp` RETURNS and
    //    SOLIDWORKS then takes seconds to tear a session down (10 CEF
    //    subprocesses, three out-of-process servers, the journal). A single
    //    reading 1.5 s later called that "declined" — and the ladder's
    //    response to a decline is to escalate, sending a window message into
    //    an application that was already exiting. So poll at the settle
    //    interval up to THIS rung's own budget, and stop early on the two
    //    answers that end the ladder: gone, or a dialog on screen.
    let interval = std::time::Duration::from_millis(
        std::env::var("SWX_QUIT_SETTLE_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500),
    );
    // How long to WAIT for the exit: this rung's own budget, because that is
    // the longest the rung is allowed to take at all. SWX_QUIT_EXIT_WAIT_SECS
    // shortens it — the suite and the gate both drive many rungs and neither
    // needs a real half-minute to watch a simulated process go.
    let wait_secs = std::env::var("SWX_QUIT_EXIT_WAIT_SECS")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .unwrap_or_else(|| quit::rung_budget_secs(rung));
    let wait_until = std::time::Instant::now() + std::time::Duration::from_secs(wait_secs);
    let mut census;
    let mut gone;
    let mut after_windows;
    let mut new_dialog;
    let mut polls = 0u32;
    loop {
        std::thread::sleep(interval);
        polls += 1;
        census = os.processes();
        gone = quit::process_gone(census.as_ref(), &id);
        after_windows = os.windows_of_pid(id.pid);
        new_dialog = after_windows.as_ref().ok().and_then(|w| quit::dialog_census(w, os).blocking);
        if gone == Some(true) || new_dialog.is_some() || std::time::Instant::now() >= wait_until
        {
            break;
        }
    }
    let (children, survivors, unjudged) =
        census.as_ref().ok().map(|c| quit::reap_report(c)).unwrap_or_default();
    let new_windows = after_windows
        .as_ref()
        .ok()
        .map(|w| w.iter().filter(|x| x.is_dialog() && x.visible).count())
        .unwrap_or(0);

    let mut result = match gone {
        Some(true) => quit::RungResult::Quit,
        Some(false) => quit::result_from_host(&host_word),
        // A rung that cannot be MEASURED is a rung that failed.
        None => quit::RungResult::Unmeasured,
    };
    if result != quit::RungResult::Quit && new_dialog.is_some() {
        result = quit::RungResult::DialogAppeared;
    }
    let mut detail = detail;
    if polls > 1 {
        detail = Some(format!(
            "{} — the exit was measured over {polls} census reads (a rung returns before the \
             process is gone)",
            detail.unwrap_or_default()
        ));
    }
    if refs > 0 {
        detail = Some(format!(
            "{} — WARNING: {refs} COM reference(s) were still outstanding at the measurement",
            detail.unwrap_or_default()
        ));
    }

    quit::RungTry {
        rung,
        action: quit::rung_action(rung),
        result,
        process_gone: gone,
        elapsed_sec: rung_started.elapsed().as_secs_f64(),
        new_windows,
        dialog: new_dialog.clone(),
        children_alive: children,
        survivors_alive: survivors,
        unjudged_alive: unjudged,
        census_readable: census.is_ok(),
        detail,
        host_error,
        primed: false,
    }
}

/// PRIME A SESSION THAT HAS NEVER HELD A DOCUMENT: one bridge-owned scratch part,
/// created and closed through the ordinary owned-document path (default posture,
/// registered, ledgered `quit_prime`). Refused — `Err` with the reason — unless
/// the session is EMPTY, NO dialog is up, and its history says it never held a
/// document or cannot say. Never with a document open: priming is only ever done
/// to a session nobody is working in.
fn prime_session(
    ctx: &Ctx,
    os: &dyn crate::launchos::LaunchOs,
    id: &crate::quit::Identity,
    presence_overridden: bool,
) -> Result<String, String> {
    use crate::quit;
    // THE SESSION'S HISTORY IS ADVISORY — reported, never a guard (0.9.6, P1).
    // Live 2026-10-01 (pid 29108, never driven, no document ever opened): a
    // startup poll recorded everHadDocument:true, the guard refused to prime, and
    // the quit was declined twice. The trigger is the measured condition itself:
    // rung (a) declined, the session empty (re-checked below), no blocking
    // dialog, userAuthorized — once per swx_stop call.
    let ever = crate::session_history::ever_had_document(id.pid, Some(id.created_at));
    // NEVER PRIME A SESSION THE USER IS IN (0.9.9): presence is measured again
    // here — the front gate's reading is a ladder rung old.
    if !presence_overridden {
        if let Some((true, evidence)) = crate::presence::measure(ctx) {
            return Err(format!(
                "the user is working in this session ({}) — the bridge does not create and close a \
                 document in it",
                evidence.get("basis").and_then(|b| b.as_str()).unwrap_or("present")
            ));
        }
    }
    let count = ctx
        .call("app_state", json!({}), 20)
        .ok()
        .and_then(|s| s.get("documentCount").and_then(|v| v.as_u64()));
    if count != Some(0) {
        return Err(format!(
            "the session is not provably empty (documents open: {}) — a session somebody may \
             be working in is never primed",
            count.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())
        ));
    }
    match os.windows_of_pid(id.pid) {
        Ok(w) if quit::dialog_census(&w, os).blocking.is_none() => {}
        Ok(_) => return Err("a dialog is up".into()),
        Err(e) => return Err(format!("the window census could not be read: {e}")),
    }
    let made = crate::verbs3::new_document(ctx, &json!({"type": "part"}));
    if made.get("success").and_then(|v| v.as_bool()) != Some(true) {
        return Err(format!("the scratch part could not be created: {}", made.get("error").cloned().unwrap_or(Value::Null)));
    }
    let title = made.pointer("/created/title").and_then(|v| v.as_str()).unwrap_or("").to_string();
    let scratch_path = made
        .pointer("/created/path")
        .or_else(|| made.pointer("/documentIdentity/path"))
        .and_then(|v| v.as_str())
        .map(String::from);
    crate::session_history::mark(id.pid, Some(id.created_at));
    let closed = crate::verbs3::reset_workspace(ctx, &json!({"_presenceMeasuredByCaller": true}));
    let after = ctx
        .call("app_state", json!({}), 20)
        .ok()
        .and_then(|s| s.get("documentCount").and_then(|v| v.as_u64()));
    crate::ledger::record(
        "quit_prime",
        json!({"created": title, "closed": closed.get("closed").cloned().unwrap_or(Value::Null),
               "documentCountAfter": after, "everHadDocumentBefore": ever,
               "why": "ExitApp was declined on an empty session that had never held a document \
                       (measured 2026-10-01: such a session quits once one has been opened and \
                       closed)"}),
    );
    if after != Some(0) {
        // WHOSE DOCUMENT IS STILL OPEN? If the sweep did not report closing the
        // scratch part, it is the bridge's own, and the caller is told where it
        // is and how to drain it (adversary pass 42, S6).
        let closed_it = closed
            .get("closed")
            .and_then(|v| v.as_array())
            .map(|a| a.iter().any(|t| t.as_str() == Some(title.as_str())))
            .unwrap_or(false);
        let still = ctx
            .call("app_state", json!({}), 20)
            .ok()
            .and_then(|s| s.get("documents").cloned())
            .and_then(|d| d.as_array().cloned())
            .map(|docs| {
                docs.iter().any(|d| {
                    d.get("title").and_then(|t| t.as_str()) == Some(title.as_str())
                        || (scratch_path.is_some()
                            && d.get("path").and_then(|p| p.as_str()) == scratch_path.as_deref())
                })
            });
        let ours_open = still.unwrap_or(!closed_it);
        return Err(if ours_open {
            format!(
                "the scratch part '{title}' was created but could NOT be closed — the bridge's own \
                 part is still open at {} (documents open: {}). Drain it: swx_reset_workspace closes \
                 only bridge-created documents (or swx_close_doc with that path). ExitApp is not \
                 asked again",
                scratch_path.as_deref().unwrap_or("(no path reported)"),
                after.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())
            )
        } else {
            format!(
                "the scratch part '{title}' was created and closed, but the session is not empty \
                 afterwards (documents open: {}) — somebody else's document; ExitApp is not asked again",
                after.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())
            )
        });
    }
    Ok(format!("'{title}'"))
}

fn swx_open(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let path = match arg_str(args, "path") {
        Some(p) => p.to_string(),
        None => {
            return err(
                "path is required",
                "Pass the .sldprt/.sldasm/.slddrw path. swx_find_files locates candidates.",
            )
        }
    };
    let job = ctx.jobs.start(
        "open",
        format!("Opening {}", crate::comhost::mock::base_name(&path)),
        30,
    );
    // Whether opening a document may START SOLIDWORKS is the same decision
    // swx_start makes, and it is made in the same place.
    let may = may_launch(crate::is_mock(), &ctx.detection());
    // Ask the COM host whether it is running, not `detection` — detection is a
    // process scan, which is always false under the mock, and this branch is
    // exactly the one that must be reachable from a test.
    let running = ctx
        .call("status", json!({}), 20)
        .ok()
        .and_then(|s| s.get("running").and_then(|r| r.as_bool()))
        .unwrap_or(false);
    if !running && !may {
        return err_code(
            "host_app_launch_blocked",
            "SOLIDWORKS is not running, and this edition cannot be started programmatically",
            "swx_open will not launch it: on SOLIDWORKS Connected / 3DEXPERIENCE a programmatic \
             launch spawns a process that refuses with \"SOLIDWORKS Design must be launched from \
             the 3DEXPERIENCE Platform\" and never attaches, leaving a modal on the user's \
             screen. ASK THE USER to start SOLIDWORKS from the 3DEXPERIENCE Platform or its \
             desktop shortcut, then retry. Do not try to launch sldworks.exe by hand.",
        );
    }
    let read_only = arg_bool(args, "readOnly", true);
    // readOnly:false takes a WRITE LOCK on the user's file and converts-and-
    // dirties an older-version document in memory. That is a write, and it goes
    // through the same choke point as every other one.
    let mut open_grant = crate::writes::Grant { host_args: json!({}), user_write: false };
    if !read_only {
        let (session, _open, _active, _truncated) = crate::verbs3::ownership_context(ctx);
        let owned = ctx.owns_doc("", Some(&path), session);
        let target = crate::verbs::Target {
            title: crate::comhost::mock::base_name(&path).to_string(),
            path: Some(path.clone()),
            owned,
        };
        open_grant = match crate::writes::authorize_in(
            ctx,
            args,
            "swx_open {readOnly:false}",
            crate::writes::Kind::ModifyDocument,
            Some(&target),
            None,
        ) {
            Ok(g) => g,
            Err(e) => return e,
        };
    }
    let mut call_args = crate::writes::with_grant(json!({}), &open_grant);
    if let Value::Object(m) = &mut call_args {
        m.insert("path".into(), Value::from(path.clone()));
        m.insert("configuration".into(), args.get("configuration").cloned().unwrap_or(Value::Null));
        m.insert("launch".into(), Value::Bool(may));
        m.insert("readOnly".into(), Value::Bool(read_only));
    }
    let session_before = crate::verbs3::session_identity(ctx);
    match ctx.call("open", call_args, 280) {
        Err(e) => sidecar_error(e),
        Ok(r) => {
            // THE EFFECT (0.11.3): the document is open in the SAME, LIVE session.
            // A SOLIDWORKS that was not running before the open (a classic
            // install the open launched) is read as the session now answering.
            let before = session_before.or_else(|| crate::verbs3::session_identity(ctx));
            let opened_path = r.pointer("/doc/path").and_then(|p| p.as_str()).filter(|p| !p.is_empty()).map(String::from).unwrap_or_else(|| path.clone());
            let opened_title = r.pointer("/doc/title").and_then(|t| t.as_str()).map(String::from);
            let effect = match crate::verbs3::verify_effect(ctx, "swx_open", before, opened_title.as_deref(), Some(&opened_path)) {
                Ok(v) => v,
                Err(e) => return e,
            };
            ok_merge(
            r,
            json!({
                "effect": effect,
                "windowEtiquette": "background",
                "progress": job.finished(),
                "_hint": "Open and active, 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 in memory just by being looked at. Pass \
                          readOnly:false if you intend to modify it (which also needs the write \
                          opt-in unless the bridge created it). swx_bom (assemblies), swx_mass_properties, \
                          swx_props_get, or swx_export next. NOTE: this verb does NOT start \
                          SOLIDWORKS on the 3DEXPERIENCE / Connected editions — it cannot, and \
                          answers host_app_launch_blocked instead. On a classic desktop install \
                          it will launch it, and the progress frame reports what that cost.",
            }),
        )
        }
    }
}

/// Is the target document dirty? `None` means WE DO NOT KNOW.
///
/// The distinction is the point. The previous version collapsed both "clean"
/// and "the COM call failed" into `false` with two `unwrap_or(false)`s, so a
/// SOLIDWORKS that would not answer produced a confident "nothing to lose here"
/// and the close proceeded unconfirmed. Unknown dirtiness is treated as DIRTY
/// by the caller.
fn doc_is_dirty(ctx: &Ctx, target: &crate::verbs::Target) -> Option<bool> {
    let docs = ctx.call("docs", json!({}), 30).ok()?;
    let list = docs.get("docs")?.as_array()?;
    let found = list.iter().find(|x| {
        x.get("title").and_then(|t| t.as_str()).map(|t| same_title(t, &target.title)).unwrap_or(false)
            && x.get("path").and_then(|p| p.as_str()).filter(|s| !s.is_empty()).map(String::from)
                == target.path
    })?;
    found.get("dirty")?.as_bool()
}

fn swx_close_doc(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    // `name` is this verb's Fusion-parity spelling for "the document, by title".
    // Feed it to the SAME resolver every other document verb uses, so an
    // ambiguous title is fatal here too and the com host is told exactly which
    // document to close — by PATH where there is one.
    let mut resolve_args = args.clone();
    if let Value::Object(m) = &mut resolve_args {
        if let Some(n) = args.get("name").cloned() {
            m.entry("targetDocument").or_insert(n);
        }
    }
    let target = match crate::verbs3::resolve_document(ctx, &resolve_args, "swx_close_doc") {
        Ok(t) => t,
        Err(e) => return e,
    };

    let save = arg_bool(args, "save", false);
    let confirmed = arg_bool(args, "confirm", false) && arg_str(args, "reason").is_some();
    let dirty = doc_is_dirty(ctx, &target);

    // TWO ways this verb can touch a document that is not ours, and BOTH need
    // permission. The first was missed for five rounds:
    //
    //   * `save:true` WRITES THE USER'S FILE. It is byte-for-byte the same
    //     `Save3` call that `swx_save` makes — and `swx_save` is gated. So
    //     `swx_props_set` (ungated by design) followed by
    //     `swx_close_doc {save:true}` modified their file on disk with no
    //     confirmation, no reason and no audit trail. That falsified the exact
    //     invariant both the docs and props_set's own `_hint` used to justify
    //     leaving props_set open: "nothing reaches the user's file until the
    //     save does". It only holds if EVERY persist route is gated.
    //   * `save:false` on a dirty document DISCARDS their work.
    // Closing a document the bridge did not create is a change to the user's
    // session — their window, their undo history — even when the document is
    // clean. Gated at the same choke point so the refusal comes from here with
    // a useful message rather than from the com host's terser veto.
    //
    // AND THE GRANT MUST REACH THE HOST. The result of this authorization used
    // to be DISCARDED — only the `save:true` branch produced a grant, so
    // `close_doc {save:false}` on a foreign document was authorized by the
    // server and then sent to the com host with no evidence of it. The host,
    // holding its own independent veto, refused. Live on 2026-09-03: Drew
    // authorized closing his own leftover documents, every argument was
    // correct, and the bridge still answered "this document was not created by
    // this bridge". The server has to TELL the host what the human allowed.
    let mut grant = crate::writes::Grant::none();
    if !target.owned {
        match crate::writes::authorize_in(
            ctx,
            args,
            "swx_close_doc",
            crate::writes::Kind::CloseDocument,
            Some(&target),
            None,
        ) {
            Ok(g) => grant.merge(g),
            Err(e) => return e,
        }
    }
    // save:true WRITES THE USER'S FILE — byte for byte the same `Save3` that
    // swx_save makes. It goes through the same choke point, and the union of
    // both grants is what the host is told.
    if save {
        match crate::writes::authorize_in(ctx, args, "swx_close_doc {save:true}", crate::writes::Kind::SaveInPlace, Some(&target), None) {
            Ok(g) => grant.merge(g),
            Err(e) => return e,
        }
    }
    // save:false on a document that is not ours DISCARDS work. Unknown
    // dirtiness is treated as dirty: an unanswerable question is not permission.
    if !save && !target.owned && dirty != Some(false) && !confirmed {
        let known = dirty == Some(true);
        return err_code(
            "unsaved_documents",
            format!(
                "'{}' {} and this bridge did not create it",
                target.describe(),
                if known {
                    "has unsaved changes"
                } else {
                    "may have unsaved changes (SOLIDWORKS would not say)"
                }
            ),
            "Closing it now would throw that work away. swx_save first, or pass confirm:true \
             WITH a reason if the user has said the changes are disposable. When SOLIDWORKS \
             cannot be asked whether a document is dirty, this verb assumes it IS. Documents \
             this bridge created (swx_new_document / swx_import) close without asking.",
        );
    }

    let mut call_args = crate::writes::with_grant(target.args(), &grant);
    if let Value::Object(m) = &mut call_args {
        m.insert("save".into(), Value::Bool(save));
    }
    match ctx.call("close_doc", call_args, 60) {
        Err(e) => sidecar_error(e),
        Ok(r) => {
            // A scratch document that is gone is no longer ours to sweep.
            ctx.forget_scratch_doc(&target.title, target.path.as_deref());
            // THE AUDIT TRAIL. Both docs promised one here and there was none —
            // a document promising an accountability record that does not exist
            // is worse than one that promises nothing.
            crate::ledger::record(
                "document_closed",
                json!({
                    "title": target.title,
                    "path": target.path,
                    "saved": save,
                    "bridgeCreated": target.owned,
                    "by": "close_doc",
                }),
            );
            if !target.owned && save {
                crate::ledger::record(
                    "user_document_saved",
                    json!({
                        "document": target.title,
                        "path": target.path,
                        "via": "swx_close_doc{save:true}",
                        "reason": arg_str(args, "reason"),
                    }),
                );
            }
            if !target.owned && !save && dirty != Some(false) {
                crate::ledger::record(
                    "user_document_changes_discarded",
                    json!({
                        "document": target.title,
                        "path": target.path,
                        "dirtyKnown": dirty.is_some(),
                        "reason": arg_str(args, "reason"),
                    }),
                );
            }
            ok_merge(
                r,
                json!({
                    "document": target.title,
                    "path": target.path,
                    "saved": save,
                    "bridgeCreatedDocument": target.owned,
                    "_hint": "Target it with `path` (unambiguous) or `name` (its title — NOT \
                              unique, so a title matching two open documents is refused with \
                              ambiguous_target); with neither, the ACTIVE document is closed. On \
                              a document this bridge did not create: save:true WRITES their file \
                              and needs confirm:true + reason (errorCode not_bridge_document), \
                              and save:false on one with unsaved changes needs the same \
                              (errorCode unsaved_documents). Every close is recorded in \
                              swx_action_ledger.",
                }),
            )
        }
    }
}

fn swx_save(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    // THE PERSIST STEP. swx_props_set stays ungated — it writes a named
    // metadata field the caller spelled out, in memory, and annotating the
    // user's parts with PartNo/Material is this bridge's core BOM workflow. But
    // that argument only holds while nothing can commit it to their file behind
    // their back, and this verb is what commits it. So the write to disk is
    // where the gate belongs.
    //
    // This tightens a v0.2 contract. It is a deliberate break, in the safe
    // direction: a caller that means it passes confirm:true and a reason, and
    // the change is recorded in swx_action_ledger.
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_save") {
        Ok(t) => t,
        Err(e) => return e,
    };
    let grant = match crate::writes::authorize_in(ctx, args, "swx_save", crate::writes::Kind::SaveInPlace, Some(&target), None) {
        Ok(g) => g,
        Err(e) => return e,
    };
    let started = std::time::Instant::now();
    match ctx.call("save", crate::writes::with_grant(target.args(), &grant), 110) {
        Err(e) => sidecar_error(e),
        Ok(r) => {
            crate::ledger::record_time("swx_save", started.elapsed().as_secs_f64());
            if !target.owned {
                crate::ledger::record(
                    "user_document_saved",
                    json!({"document": target.title, "path": target.path}),
                );
            }
            ok_merge(
                r,
                json!({
                    "document": target.title,
                    "bridgeCreatedDocument": target.owned,
                    "_hint": "Saved in place. errors/warnings are SOLIDWORKS' own save codes — 0 \
                              means clean. This is the step that makes a swx_props_set permanent, \
                              which is why saving a document the bridge did not create needs \
                              confirm:true and a reason.",
                }),
            )
        }
    }
}

const EXPORT_FORMATS: &[(&str, &str)] = &[
    ("step", ".step"),
    ("stp", ".step"),
    ("iges", ".igs"),
    ("igs", ".igs"),
    ("stl", ".stl"),
    ("pdf", ".pdf"),
    ("dxf", ".dxf"),
    ("dwg", ".dwg"),
    ("png", ".png"),
    ("jpg", ".jpg"),
    ("parasolid", ".x_t"),
    // Added in v0.3 after exporting each one from a real solid on a live
    // SOLIDWORKS Design Professional for Makers 2026 and checking the bytes.
    ("x_t", ".x_t"),
    ("x_b", ".x_b"),
    ("sat", ".sat"),
    ("3mf", ".3mf"),
    ("glb", ".glb"),
    ("ply", ".ply"),
    ("amf", ".amf"),
    ("3dxml", ".3dxml"),
    ("tif", ".tif"),
    ("tiff", ".tif"),
    ("jpeg", ".jpg"),
    ("xaml", ".xaml"),
    // OBJ and U3D are listed NOT because they work — a live part refused both
    // with swFileSaveError_e 256 — but so the caller gets SOLIDWORKS' own,
    // accurate refusal ("format not supported for this document type") instead
    // of this bridge's generic "unsupported format", which would be a different
    // and less useful claim. Same reasoning as DXF/DWG, which are refused for a
    // part and expected to work from a drawing.
    ("obj", ".obj"),
    ("u3d", ".u3d"),
];

/// Destination extension for a requested format name.
pub fn export_extension(format: &str) -> Option<&'static str> {
    let f = format.trim().trim_start_matches('.').to_lowercase();
    EXPORT_FORMATS.iter().find(|(name, _)| *name == f).map(|(_, ext)| *ext)
}

fn swx_export(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let fmt = arg_str(args, "format").unwrap_or("step").trim().trim_start_matches('.').to_lowercase();
    // The `format` argument is checked for the friendly error, but it is NOT the
    // guard — SaveAs3 picks the format off the DESTINATION EXTENSION, so
    // `swx_export_step {outputPath:"x.wrl"}` would otherwise sail straight past
    // a check on `format` (and the per-format aliases hard-code `format`, so for
    // them it could never fire at all). The real guard is on `out`, below.
    if crate::verbs3::BLOCKED_FORMATS.contains(&fmt.as_str()) {
        return crate::verbs3::blocked_path_error(&format!("format:{fmt}"), &fmt);
    }
    let ext = match export_extension(&fmt) {
        Some(e) => e,
        None => {
            let known: Vec<&str> = EXPORT_FORMATS.iter().map(|(n, _)| *n).collect();
            let mut e = err(
                format!("unsupported format: {fmt}"),
                format!("Formats this bridge knows: {}.", known.join(", ")),
            );
            if let Value::Object(m) = &mut e {
                m.insert("formats".into(), crate::verbs3::export_format_note());
            }
            return e;
        }
    };
    let out = match arg_str(args, "outputPath").or_else(|| arg_str(args, "out")) {
        Some(o) => match safe_out(o) {
            Ok(p) => p,
            Err(e) => return e,
        },
        None => {
            let stem = crate::comhost::mock::file_stem(arg_str(args, "path").unwrap_or("export"));
            let stamp = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_secs())
                .unwrap_or(0);
            crate::scratch::exports_dir()
                .join(format!("{stem}-{stamp}{ext}"))
                .to_string_lossy()
                .to_string()
        }
    };
    // THE guard: whatever `out` ends in is what SOLIDWORKS will try to write.
    if let Some(bad) = crate::verbs3::blocked_by_extension(&out) {
        return crate::verbs3::blocked_path_error(&out, bad);
    }
    // The extension actually written is `out`'s, which may differ from the
    // requested format name (e.g. format:"stp" -> .step) — echo the real one.
    let out_ext = std::path::Path::new(&out)
        .extension()
        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
        .unwrap_or_else(|| ext.to_string());
    crate::scratch::prune();
    // Copy mode, so this does not change the document — but it must still
    // EXPORT the document the caller named. `targetDocument` used to be
    // accepted and ignored here, which meant the file on disk could come from a
    // different model than the caller believed.
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_export") {
        Ok(t) => t,
        Err(e) => return e,
    };
    let grant = match crate::writes::authorize_in(ctx, args, "swx_export", crate::writes::Kind::ExportCopy, Some(&target), Some(&out)) {
        Ok(g) => g,
        Err(e) => return e,
    };
    let mut call_args = crate::writes::with_grant(target.args(), &grant);
    if let Value::Object(m) = &mut call_args {
        m.insert("out".into(), Value::from(out.clone()));
    }
    let job = ctx.jobs.start("export", format!("Exporting {}", fmt.to_uppercase()), 45);
    match ctx.call("export", call_args, 280) {
        Err(e) => sidecar_error(e),
        Ok(r) => ok_merge(
            r,
            json!({
                "format": fmt,
                "document": target.title,
                "outputExtension": out_ext,
                "formats": crate::verbs3::export_format_note(),
                "progress": job.finished(),
                "_hint": "Export written on the DESKTOP filesystem. From a cloud container, pull \
                          the bytes back with ab's file verbs (pull_file). Pass `out` to choose \
                          the path; without it the file lands in the bridge's own exports \
                          folder, which is pruned after 48h.",
            }),
        ),
    }
}

fn swx_pack_and_go(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let out = match arg_str(args, "out") {
        Some(o) => match safe_out(o) {
            Ok(p) => p,
            Err(e) => return e,
        },
        None => {
            return err(
                "out (destination directory) is required",
                "Pack and Go copies the doc plus every reference into `out` so the set is \
                 portable — that is the verb for handing a design to someone else.",
            )
        }
    };
    // Pack and Go writes MANY files into `out`. A destination that already has
    // content is refused for the same reason a single existing file is.
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_pack_and_go") {
        Ok(t) => t,
        Err(e) => return e,
    };
    // M1: the server passed `destination:None` for an empty/absent directory,
    // which skipped the OWNERSHIP question entirely — so it allowed Pack and Go
    // on any document, while the real host refused it. The verb was dead on a
    // real machine and green in the suite. Both sides now ask the same question:
    // the document must be ours, and an occupied destination is an overwrite.
    // The two vetoes must ask the SAME question. The server asked "is the
    // directory non-empty?" while the host asked `Path::exists()`, so a
    // bridge-owned assembly into an existing EMPTY directory was granted by one
    // and refused by the other: fail-closed, but the verb was dead for its most
    // natural case and the two guards were answering different things. Both now
    // use existence, which is also the rule every other destination follows.
    let grant = match crate::writes::authorize_in(
        ctx,
        args,
        "swx_pack_and_go",
        crate::writes::Kind::CollectFiles,
        Some(&target),
        Some(out.as_str()),
    ) {
        Ok(g) => g,
        Err(e) => return e,
    };
    let job = ctx.jobs.start("pack_and_go", "Collecting references (Pack and Go)", 90);
    let mut call_args = crate::writes::with_grant(target.args(), &grant);
    if let Value::Object(m) = &mut call_args {
        m.insert("out".into(), Value::from(out.clone()));
        m.insert("includeDrawings".into(), Value::Bool(arg_bool(args, "includeDrawings", false)));
    }
    match ctx.call("pack_and_go", call_args, 580) {
        Err(e) => sidecar_error(e),
        Ok(r) => ok_merge(
            r,
            json!({
                "progress": job.finished(),
                "_hint": "Statuses of 0 mean saved OK. The folder now holds a self-contained \
                          copy of the design. includeDrawings:true pulls the drawings in too.",
            }),
        ),
    }
}

// ── verbs: properties ───────────────────────────────────────────────────────

fn swx_props_get(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_props_get") {
        Ok(t) => t,
        Err(e) => return e,
    };
    let mut call_args = target.args();
    if let Value::Object(m) = &mut call_args {
        m.insert("configuration".into(), args.get("configuration").cloned().unwrap_or_else(|| Value::from("")));
    }
    match ctx.call("props_get", call_args, 60) {
        Err(e) => sidecar_error(e),
        Ok(r) => ok_merge(
            r,
            json!({
                "_hint": "File-level custom properties (the '' configuration) drive BOMs and \
                          title blocks. Pass configuration:<name> for config-specific ones; \
                          `configurations` lists what this document has.",
            }),
        ),
    }
}

fn swx_props_set(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let props = match args.get("properties").and_then(|p| p.as_object()) {
        Some(p) if !p.is_empty() => p.clone(),
        _ => {
            return err(
                "properties (object of name -> value) is required",
                r#"e.g. {"properties": {"PartNo": "BRK-100", "Material": "6061-T6"}}"#,
            )
        }
    };
    // props_set is not ownership-gated (see swx_save), but it MUST act on the
    // document the caller named rather than falling through to ActiveDoc — the
    // same resolution every document verb shares.
    //
    // It uses the READ resolver deliberately. An earlier version called the
    // gated one with a confirm and a reason THE BRIDGE WROTE FOR ITSELF
    // ("swx_props_set is in-memory until swx_save, which is gated"), which put
    // a self-authored justification into the user's audit trail — and one that
    // a close_doc{save:true} bypass then made false. A bridge does not get to
    // enter its own consent into the record.
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_props_set") {
        Ok(t) => t,
        Err(e) => return e,
    };
    // C2: "in memory until a GATED save" ignored the persist route that is not
    // gated and never could be — THE USER. This leaves their document dirty, so
    // SOLIDWORKS prompts "Save changes?" on close and their own Ctrl+S writes
    // bridge-authored data into their file. Modifying their document is the
    // harm, whoever eventually commits it.
    let grant = match crate::writes::authorize_in(
        ctx,
        args,
        "swx_props_set",
        crate::writes::Kind::ModifyDocument,
        Some(&target),
        None,
    ) {
        Ok(g) => g,
        Err(e) => return e,
    };
    if !target.owned {
        crate::ledger::record(
            "user_document_properties_set",
            json!({
                "document": target.title,
                "path": target.path,
                "properties": props.keys().cloned().collect::<Vec<_>>(),
                "note": "in memory only; swx_save or swx_close_doc{save:true} would persist it, \
                         and both are gated",
            }),
        );
    }
    let mut call_args = crate::writes::with_grant(target.args(), &grant);
    if let Value::Object(m) = &mut call_args {
        m.insert("configuration".into(), args.get("configuration").cloned().unwrap_or_else(|| Value::from("")));
        m.insert("properties".into(), Value::Object(props));
    }
    match ctx.call("props_set", call_args, 60) {
        Err(e) => sidecar_error(e),
        Ok(r) => ok_merge(
            r,
            json!({
                "_hint": "Properties are set in MEMORY — call swx_save to persist them to the \
                          file, or the next close discards them. That is also why this verb is \
                          not ownership-gated and swx_save is: nothing here reaches the user's \
                          file until the save does.",
            }),
        ),
    }
}

// ── verbs: analysis ─────────────────────────────────────────────────────────

fn swx_bom(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    // A BOM off the WRONG same-titled part is a bill of materials somebody
    // orders from — the exact harm the resolver exists to prevent, and this verb
    // was bypassing it.
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_bom") {
        Ok(t) => t,
        Err(e) => return e,
    };
    let mut call_args = target.args();
    if let Value::Object(m) = &mut call_args {
        m.insert("topLevelOnly".into(), Value::Bool(arg_bool(args, "topLevelOnly", false)));
    }
    let (result, progress) = {
        let job = ctx.jobs.start("bom", "Walking assembly components", 60);
        match ctx.call("bom", call_args, 280) {
            Err(e) => return sidecar_error(e),
            Ok(r) => (r, job.finished()),
        }
    };

    let mut rows: Vec<Value> =
        result.get("rows").and_then(|r| r.as_array()).cloned().unwrap_or_default();
    crate::bom::sort_rows(&mut rows);
    let columns = crate::bom::prop_columns(&rows);

    let mut out = Map::new();
    out.insert("success".into(), Value::Bool(true));
    out.insert("doc".into(), result.get("doc").cloned().unwrap_or(Value::Null));
    out.insert("topLevelOnly".into(), result.get("topLevelOnly").cloned().unwrap_or(json!(false)));
    out.insert("rowCount".into(), json!(rows.len()));
    out.insert("rows".into(), Value::Array(rows.clone()));
    out.insert("progress".into(), progress);

    if arg_str(args, "format").unwrap_or("json").to_lowercase() == "csv" {
        let content = crate::bom::to_csv(&rows, &columns);
        out.insert("csv".into(), Value::from(content.clone()));
        let csv_path = match arg_str(args, "out") {
            Some(p) => match safe_out(p) {
                Ok(p) => p,
                Err(e) => return e,
            },
            None => {
                let doc_path = result
                    .get("doc")
                    .and_then(|d| d.get("path"))
                    .and_then(|p| p.as_str())
                    .unwrap_or("bom");
                let stamp = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map(|d| d.as_secs())
                    .unwrap_or(0);
                crate::scratch::exports_dir()
                    .join(format!("{}-bom-{stamp}.csv", crate::comhost::mock::file_stem(doc_path)))
                    .to_string_lossy()
                    .to_string()
            }
        };
        // C1: this write is done by the SERVER (`std::fs::write`), so neither
        // the com host's veto nor `safe_out` — which only checks for an absolute
        // path with no `..` — stands between it and any file on disk. It
        // overwrote an existing file and reported success. It is the same
        // destination rule as every other write, so it goes through the same
        // choke point.
        if let Err(e) = crate::writes::authorize_in(
            ctx,
            args,
            "swx_bom {format:\"csv\"}",
            crate::writes::Kind::ExportCopy,
            None,
            Some(&csv_path),
        ) {
            return e;
        }
        crate::scratch::prune();
        match std::fs::write(&csv_path, content.as_bytes()) {
            Ok(()) => {
                out.insert("csvPath".into(), Value::from(csv_path));
            }
            Err(e) => {
                out.insert("csvWriteError".into(), Value::from(e.to_string()));
            }
        }
    }
    out.insert(
        "_hint".into(),
        Value::from(
            "Quantities aggregate resolved component instances per (file, configuration); \
             suppressed components are excluded. format:'csv' adds `csv` (the text) and \
             `csvPath` (a file on the DESKTOP disk), with one column per custom property found \
             anywhere in the tree. topLevelOnly:true gives the top-level-only view; the default \
             is the full flattened tree.",
        ),
    );
    Value::Object(out)
}

fn swx_mass_properties(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_mass_properties") {
        Ok(t) => t,
        Err(e) => return e,
    };
    let mut call_args = target.args();
    if let Value::Object(m) = &mut call_args {
        m.insert("useSystemUnits".into(), Value::Bool(arg_bool(args, "useSystemUnits", true)));
    }
    match ctx.call("mass_properties", call_args, 110) {
        Err(e) => sidecar_error(e),
        Ok(r) => ok_merge(
            r,
            json!({
                "_hint": "SI units: kg, m^3, m^2, m. Mass is only as good as the material \
                          assignment — a 0 or default density means no material is set on the \
                          model, so report the density alongside the mass.",
            }),
        ),
    }
}

fn swx_interference(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let job = ctx.jobs.start("interference", "Running interference detection", 90);
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_interference") {
        Ok(t) => t,
        Err(e) => return e,
    };
    let mut call_args = target.args();
    if let Value::Object(m) = &mut call_args {
        m.insert("coincidenceCounts".into(), Value::Bool(arg_bool(args, "coincidenceCounts", false)));
    }
    match ctx.call("interference", call_args, 580) {
        Err(e) => sidecar_error(e),
        Ok(r) => ok_merge(
            r,
            json!({
                "progress": job.finished(),
                "_hint": "READ `count` CAREFULLY, and never with a truthiness check. \
                          count==0 means NO INTERFERENCES at the components' CURRENT positions \
                          — a snapshot, not a motion study. count==null means SOLIDWORKS would \
                          NOT SAY: the enumeration failed, and that is NOT a clear result. \
                          `null` is falsy in every language this bridge's callers use, so \
                          `if (!r.count)` and `if not r[\"count\"]` both turn 'I do not know' \
                          into 'all clear' — on a clearance report somebody builds a physical \
                          part from. Branch on `countKnown` (or `count === 0`) instead, and \
                          re-run the check when it is false. `listed` is how many interferences \
                          came back in the list; a count that disagrees with it is refused \
                          outright as `interference_enumeration_failed`. coincidenceCounts:true \
                          also flags touching faces (usually noise on a real assembly).",
            }),
        ),
    }
}

fn swx_rebuild_check(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let job = ctx.jobs.start("rebuild", "Force-rebuilding and scanning features", 45);
    let target = match crate::verbs3::resolve_document(ctx, args, "swx_rebuild_check") {
        Ok(t) => t,
        Err(e) => return e,
    };
    // C3: this calls `ForceRebuild3`, which re-solves every feature and dirties
    // the document — and which this bridge's OWN com_eval hazard list names as
    // "rebuilds the user's model". A first-class verb doing freely what the
    // escape hatch refuses is not a defensible split.
    let grant = match crate::writes::authorize_in(
        ctx,
        args,
        "swx_rebuild_check",
        crate::writes::Kind::ModifyDocument,
        Some(&target),
        None,
    ) {
        Ok(g) => g,
        Err(e) => return e,
    };
    match ctx.call("rebuild_check", crate::writes::with_grant(target.args(), &grant), 280) {
        Err(e) => sidecar_error(e),
        Ok(r) => ok_merge(
            r,
            json!({
                "progress": job.finished(),
                "_hint": "errorCount==0 → the model rebuilds clean. featureErrors names the \
                          broken features — this is the design-health first check, and worth \
                          running before any BOM or mass number is quoted.",
            }),
        ),
    }
}

// ── verbs: files on disk ────────────────────────────────────────────────────

fn swx_find_files(_ctx: &Ctx, args: &Value) -> Value {
    let roots: Vec<String> = match args.get("roots").or_else(|| args.get("root")) {
        Some(Value::String(s)) => vec![s.clone()],
        Some(Value::Array(a)) => {
            a.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect()
        }
        _ => Vec::new(),
    };
    let roots = if roots.is_empty() { default_roots() } else { roots };

    // `types` accepts an array OR a bare string ("assembly"), matching Python's
    // acceptance; a lone string becomes a one-element list.
    let types: Option<Vec<String>> = match args.get("types") {
        Some(Value::String(s)) => Some(vec![s.clone()]),
        Some(Value::Array(a)) => {
            Some(a.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect())
        }
        _ => None,
    };
    let want = crate::findfiles::wanted_extensions(types.as_deref());
    let query = arg_str(args, "query").unwrap_or("");
    // `limit` accepts a number OR a numeric string ("50"), like Python's int().
    let limit = args
        .get("limit")
        .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
        .unwrap_or(200)
        .max(1) as usize;

    let (hits, truncated) =
        crate::findfiles::search(&roots, &want, query, limit, Duration::from_secs(150));

    ok(json!({
        "count": hits.len(),
        "truncated": truncated,
        "roots": roots,
        "files": hits.iter().map(|h| h.to_json()).collect::<Vec<_>>(),
        "_hint": "Newest first. Pass roots:[...] to search specific folders, query:'name' to \
                  filter, types:['assembly'] to narrow (part/assembly/drawing). ~$ lock files \
                  are skipped. truncated:true means the limit or the 150s budget cut the walk \
                  short — narrow the roots. swx_open takes any of these paths, and this verb \
                  works with SOLIDWORKS closed or not installed.",
    }))
}

pub(crate) fn default_roots() -> Vec<String> {
    let home = std::env::var("USERPROFILE")
        .or_else(|_| std::env::var("HOME"))
        .unwrap_or_else(|_| ".".to_string());
    let home = std::path::Path::new(&home);
    vec![
        home.join("Documents").to_string_lossy().to_string(),
        home.join("Desktop").to_string_lossy().to_string(),
    ]
}

// ── verbs: escape hatch, screenshot, uninstall, describe ────────────────────

fn swx_screenshot(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    // A free-text `titleContains` forwarded straight to ab made this verb a
    // general desktop screenshot tool: `{"titleContains":"1Password"}` captured
    // whatever matched. It is catalogued as "screenshot the SOLIDWORKS window",
    // it is not gated, and a screenshot is the user's data.
    //
    // So: capture BY HANDLE, using the hwnd COM reports, which is the only
    // window this verb has any business looking at. An explicit `hwnd` is
    // honoured but must be one attributable to SOLIDWORKS.
    let (main, sw_pid) = crate::verbs3::window_identity_for(ctx);
    let want = args.get("hwnd").and_then(|v| v.as_i64());
    let hwnd = match want {
        Some(h) => {
            let ours = crate::abwin::list_windows(Duration::from_secs(20))
                .map(|all| crate::abwin::solidworks_windows(&all, main, sw_pid))
                .unwrap_or_default()
                .into_iter()
                .any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h));
            if !ours && Some(h) != main {
                return err_code(
                    "window_not_attributed",
                    format!("hwnd {h} cannot be proved to belong to SOLIDWORKS"),
                    "This verb captures SOLIDWORKS' own windows. swx_window_info lists them with \
                     their handles. For anything else on the desktop, ab's own \
                     desktop_screenshot_window is the right tool and carries the user's approval \
                     prompt.",
                );
            }
            Some(h)
        }
        None => main,
    };
    let Some(hwnd) = hwnd else {
        return err_code(
            "no_solidworks_window",
            "SOLIDWORKS' window handle is not available",
            "COM reports the main window handle; with SOLIDWORKS closed there is nothing to \
             capture. swx_status says whether it is running.",
        );
    };
    let r = crate::caller::ad_callback(
        "desktop_screenshot_window",
        json!({"hwnd": hwnd}),
        Duration::from_secs(30),
    );
    // ab's response drives success. A present-but-null `error` is NOT a failure
    // (many verbs stamp "error":null on success), so only a non-null, non-empty
    // error counts against it.
    let has_error = matches!(r.get("error"), Some(Value::String(s)) if !s.is_empty());
    let ok_flag = r.get("success").and_then(|v| v.as_bool()).unwrap_or(true) && !has_error;
    json!({
        "success": ok_flag,
        "hwnd": hwnd,
        "ab": r,
        "_hint": "Delegates to ab's generic desktop_screenshot_window, BY HANDLE — the handle COM \
                  reports for SOLIDWORKS, or an explicit `hwnd` that swx_window_info attributes \
                  to it. It captures that window and the dialogs it owns, and nothing else on \
                  the desktop. SOLIDWORKS must be running; swx_state tells you whether it is.",
    })
}

fn swx_com_eval(ctx: &Ctx, args: &Value) -> Value {
    gate!(ctx);
    let expr = match arg_str(args, "expr") {
        Some(e) => e.to_string(),
        None => {
            return err(
                "expr is required",
                "A dotted call chain rooted at $sw (the SldWorks.Application) or $doc (the \
                 active document), e.g. \"$sw.RevisionNumber()\" or \
                 \"$doc.Extension.NeedsRebuild2()\". Arguments may be quoted strings, numbers \
                 or true/false.",
            )
        }
    };
    // THE SIXTH ROAD. `$doc.SaveAs3("C:\\x.wrl",0,1)` reaches the COM member
    // directly, skipping the guarded `save_as3` helper and every rule above it:
    // the blocked-extension check, Copy mode, and the ownership gate. It is the
    // exact call that hung SOLIDWORKS for 110 seconds. So the escape hatch is
    // for PROTOTYPING READS — a mutation belongs in a real verb, where the
    // guards live.
    if let Some((bad, why)) = crate::comhost::expr::refused_member(&expr) {
        return err_code(
            "eval_member_blocked",
            format!("swx_com_eval will not call '{bad}': {why}"),
            "This verb evaluates READS ONLY — an allowlist, not a list of banned members. \
             Anything beginning Get/Is/Has/Can/Enum is allowed, plus a short list of navigation \
             and scalar properties; everything else is refused, INCLUDING members nobody has \
             thought about yet. That is deliberate: a denylist has to anticipate every dangerous \
             member of an API with thousands, and the first version missed SendMsgToUser2 (a \
             modal on the user's screen that wedges the host), SetUserPreferenceToggle and \
             ForceRebuild3. Reaching a write here would also bypass every guard the real verbs \
             enforce — the blocked-extension check, Copy mode, and the ownership gate. If you \
             have proved a mutation is right, it belongs in a verb; if a genuine READ is \
             refused, add it to expr.rs READ_MEMBERS. File either on the bridge's wiki page.",
        );
    }
    if arg_str(args, "reason").is_none() {
        return err_code(
            "reason_required",
            "swx_com_eval drives the live SOLIDWORKS COM object directly; pass reason:\"why\"",
            "This verb exists to prototype new capabilities against a real install before they \
             become real verbs. The reason is self-asserted and used for the log, not for \
             authorization.",
        );
    }
    match ctx.call("eval", json!({"expr": expr}), 110) {
        Err(e) => sidecar_error(e),
        Ok(r) => ok_merge(
            r,
            json!({
                "_hint": "Late-bound IDispatch call chain, evaluated on the com host. If what \
                          you just proved out is useful, it belongs in a real verb — file it on \
                          the bridge's wiki page.",
            }),
        ),
    }
}

fn swx_uninstall(ctx: &Ctx, args: &Value) -> Value {
    // shutdown() is contention-safe (kills by pid if a long verb holds the
    // lock), so the cascade cannot hang here.
    ctx.com.shutdown();
    let mut removed: Vec<String> = vec!["com host process".to_string()];

    // ONLY the bridge's own bookkeeping and exports subtree, best-effort per
    // item — never the data root itself, never its recovery folders, never the
    // ledger.
    let (mut artifacts, failed) = crate::scratch::remove_artifacts();
    removed.append(&mut artifacts);

    json!({
        "success": failed.is_empty(),
        "removed": removed,
        "failed": failed,
        "cascade": arg_bool(args, "cascade", false),
        "ledgerPreserved": true,
        // A recovery file can be the ONLY copy of work the user had not saved.
        "recoveryPreserved": true,
        "dataRoot": crate::scratch::root().to_string_lossy(),
        "_hint": "Idempotent, non-interactive, best-effort. This removes the bridge's own \
                  bookkeeping and its exports subtree, and nothing else: the data root itself, \
                  ab's install cache, SOLIDWORKS, its settings and every user document are \
                  never touched. A file still locked open is reported in `failed`, not fought \
                  over. The RECOVERY FOLDERS are DELIBERATELY KEPT — a rescued file can be the \
                  only copy of work the user had not saved — and so is swx_action_ledger's log: \
                  this verb is ungated (ab calls it on app uninstall), so deleting the audit \
                  trail here would let a caller erase the record of what it did by asking the \
                  bridge to tidy up. Removing `dataRoot` is the user's to do.",
    })
}

// ── the catalog ─────────────────────────────────────────────────────────────

/// (verb, risk, summary). The one place the surface is declared — `swx_describe`
/// renders it, dispatch routes from it, and the test suite checks it against
/// bridge.json in both directions.
pub const CATALOG: &[(&str, &str, &str)] = &[
    ("swx_bom", "write", "assembly BOM: quantities per (file, configuration) + custom props; json or csv (csv writes a file)"),
    ("swx_close_doc", "exec", "close a document (save:true to save first; a dirty document the bridge did not create needs confirm:true)"),
    ("swx_com_eval", "exec", "maintainer escape hatch: a late-bound COM call chain on the live app"),
    ("swx_describe", "read", "this catalog"),
    ("swx_docs", "read", "list open documents"),
    ("swx_export", "write", "export by format name or destination extension: STEP/IGES/STL/3MF/GLB/SAT/X_T/X_B/PLY/AMF/3DXML/PDF/PNG/JPG/TIF (DXF/DWG from drawings; VRML refused)"),
    ("swx_find_files", "read", "search disk for .sldprt/.sldasm/.slddrw (works without SOLIDWORKS)"),
    ("swx_interference", "read", "assembly interference detection"),
    ("swx_mass_properties", "read", "mass/volume/area/CoM/density (SI units)"),
    ("swx_open", "exec", "open a document silently (part/assembly/drawing)"),
    ("swx_pack_and_go", "write", "copy a design + all references into a folder"),
    ("swx_progress", "read", "active[] progress blocks for in-flight slow verbs"),
    ("swx_props_get", "read", "read custom properties (file or configuration scope)"),
    ("swx_props_set", "write", "write custom properties (swx_save to persist)"),
    ("swx_readiness", "read", "read-only ready check; never spawns anything"),
    ("swx_rebuild_check", "exec", "force rebuild + broken-feature census (design health)"),
    ("swx_save", "write", "save the target document"),
    ("swx_screenshot", "read", "screenshot the SOLIDWORKS window via ab's generic verb"),
    ("swx_start", "process", "launch SOLIDWORKS via COM (background; cold start 30-90s); app:\"visualize\" starts SOLIDWORKS Visualize through its platform shortcut and reports what it is waiting on"),
    ("swx_state", "read", "open docs + active doc + detection (logical state of the union)"),
    ("swx_status", "read", "bridge + SOLIDWORKS detection + COM state (the everyday probe)"),
    ("swx_stop", "process", "quit SOLIDWORKS"),
    ("swx_uninstall", "write", "remove the bridge's own artifacts (ab calls this on app uninstall)"),

    // ── v0.3: the Fusion-parity surface ─────────────────────────────────────
    ("swx_new_document", "exec", "create a NEW part/assembly/drawing from the stock template"),
    ("swx_import", "exec", "import STEP/IGES/Parasolid/STL as a new document"),
    ("swx_save_as", "write", "save the document to a new path; the default RENAMES the open document, so one the bridge did not create needs confirm:true"),
    ("swx_get_app_state", "read", "running + active document + documents + workspace + busy, in one call"),
    ("swx_document_info", "read", "metadata for one document (name, type, saved, configurations)"),
    ("swx_activate_document", "exec", "make an open document the active one"),
    ("swx_close_all_documents", "exec", "close EVERY open document (confirm:true; skips unsaved by default)"),
    ("swx_reset_workspace", "exec", "close only the scratch documents this bridge created"),
    ("swx_close_import_temps", "exec", "drain leaked STEP/IGES import temporaries (…\\Temp\\swx<pid>\\IC~~\\…); default posture, never a saved user doc"),
    ("swx_close_all_safely", "exec", "save EVERY open document into a recovery folder, then close it — nothing closes before it is on disk; needs only a reason"),
    ("swx_shutdown", "exec", "close_all_safely, then quit SOLIDWORKS; idempotent, and the quit is skipped unless the session really emptied"),
    ("swx_recover_list", "read", "what previous sweeps preserved, and where — newest first"),
    ("swx_sign_in", "exec", "sign in to 3DEXPERIENCE for a launch this bridge started (or, with userAuthorized:true, any 3DEXPERIENCE ID login window, e.g. Visualize's or the platform updater's, recognized by its content; window:visualize|solidworks|desktopUpdate|<hwnd> picks one, and a named hwnd is probed by content, never refused on census grounds), from the user's local vault (the password never leaves the laptop or reaches a reply); the page is prepared first (user name, Continue), and ab's policy (sign_in_vault_policy, measured) currently keeps the password for the user to type; fails closed and never retries a rejected password"),
    ("swx_user_presence", "read", "is the user working in this SOLIDWORKS? foreground + last input + documents the bridge did not open (the guard every quit and sweep passes)"),
    ("swx_kill", "process", "force-kill SOLIDWORKS (reason + confirm; discards unsaved work)"),
    ("swx_com_respawn", "process", "respawn ONLY the bridge's own COM host child (reason): every COM reference the bridge holds is released; SOLIDWORKS is never called; refused while a verb is in flight"),
    ("swx_window_info", "read", "SOLIDWORKS' windows and its main hwnd (read-only, nothing raised)"),
    ("swx_dismiss_blocking_dialogs", "exec", "census of blocking SOLIDWORKS dialogs (native: every window of the process, with where each came from); close:true is opt-in, gated, and never touches an unattributed window — the one dialog it may CANCEL without the write opt-in is a standard file dialog with an empty name that a bridge verb provably raised"),
    ("swx_close_window", "exec", "close one window by hwnd (reason + confirm)"),
    ("swx_check_recovery", "read", "is SOLIDWORKS showing the Document Recovery dialog?"),
    ("swx_action_ledger", "read", "append-only record of what the bridge did in the background"),
    ("swx_verb_times", "read", "measured per-verb timings on THIS machine (count/p50/p90)"),
    ("swx_set_driving_badge", "write", "sticky taskbar badge showing an AI is driving SOLIDWORKS"),
    ("swx_design_info", "read", "features/sketches/bodies/components/material/rebuild state"),
    ("swx_get_parameters", "read", "global variables + named model dimensions"),
    ("swx_set_parameter", "write", "drive one named dimension (valueMm or value) and rebuild"),
    ("swx_inspect_bodies", "read", "per-body geometry read-back in millimetres (bbox, volume, area, faces)"),
    ("swx_run_macro", "exec", "run a SOLIDWORKS VBA macro (.swp/.swb/.dll) — arbitrary code, so it needs the write opt-in every time"),
    ("swx_run_script", "exec", "the modeling DSL in millimetres: new_part, sketch (rect/circle/line/polyline/arc/arc3/tangentArc/slot/polygon, sketch fillets and chamfers, construction geometry — closed contours checked before anything is drawn — on a named plane, an offset plane {offsetFrom, offsetMm}, a face {atPointMm} or a plane offset from a face), sketch_rect/sketch_circle (anchor corner|center), extrude/cut (reverse, direction one|both|midPlane, throughAll, upToNext, upToSurface, offsetFromSurface, draftDeg), revolve/revolve_cut, mirror (features or bodies), hole (plain, counterbore, countersink), fillet, chamfer, shell, linear_pattern, circular_pattern, set_appearance (rgb), set_material (densityKgM3), set_prop, save_as; a feature SOLIDWORKS returns null is an error (refuses to build into a document the bridge did not create)"),
    ("swx_selftest", "exec", "LONG-RUNNING: call it with responseMode:\"stream\" or \"job\" — level write runs 5-8 minutes, and a client's default 120 s budget cuts it off (a cut-off run's parts are closed by the next swx_selftest, and swx_status lists them). One JSON that says, verb by verb, what works on THIS SOLIDWORKS install: level:\"read\" (default) changes nothing; level:\"write\" also builds, reads back, exports, saves and CLOSES a scratch part in the bridge's own scratch folder, one modelling op per call — how a user on a release we have not run (e.g. SOLIDWORKS 2021) gets first-class support; level:\"cswa\" builds the CSWA benchmark parts shipped in resources/cswa, weighs each with swx_mass_properties and compares it with the published key (\"N of M CSWA parts within tolerance\")"),
    ("swx_add_component", "write", "insert part/assembly files as components of an assembly, positioned in mm (the verb swx_bom needs)"),
    ("swx_get_preferences", "read", "the small set of preferences this bridge can round-trip"),
    ("swx_set_preference", "write", "write one preference and READ IT BACK to verify"),
    ("swx_cloud_status", "read", "3DEXPERIENCE cloud reachability (spoiler: no COM route exists)"),
    ("swx_show_part", "exec", "open + activate + zoom-to-fit a part, in the background"),
    ("swx_show_assembly", "exec", "open + activate + zoom-to-fit an assembly, in the background"),
    ("swx_show_drawing", "exec", "open + activate + zoom-to-fit a drawing, in the background"),
    ("swx_export_step", "write", "export STEP (fusion_export_step parity: outputPath)"),
    ("swx_export_iges", "write", "export IGES"),
    ("swx_export_stl", "write", "export STL mesh"),
    ("swx_export_3mf", "write", "export 3MF"),
    ("swx_export_glb", "write", "export GLB (binary glTF)"),
    ("swx_export_sat", "write", "export ACIS SAT"),
    ("swx_export_parasolid", "write", "export Parasolid (.x_t)"),
    ("swx_export_pdf", "write", "export PDF (3D PDF from a part; a sheet from a drawing)"),
    ("swx_export_dxf", "write", "export DXF (drawings; refused for a part on this edition)"),
    ("swx_export_dwg", "write", "export DWG (drawings; refused for a part on this edition)"),
    ("swx_render_image", "write", "a high-resolution PNG of the active document from SOLIDWORKS's own viewport (RealView, ambient occlusion, shadows, perspective, a plain background; zoomFactor to frame it) at a size independent of the window — measured live: 4K in ~12 s; every display setting it changes is restored and `settings` accounts for each one asked for; the size is read back from the file"),
];

/// `swx_sign_in` — UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13). See src/signin.rs.
fn swx_sign_in(_ctx: &Ctx, args: &Value) -> Value {
    let Some(reason) = arg_str(args, "reason").filter(|r| !r.trim().is_empty()) else {
        return err_code(
            "reason_required",
            "swx_sign_in needs a reason",
            "Say why the bridge is signing the user in (it is ledgered, with who asked).",
        );
    };
    let who = crate::caller::current()
        .map(|c| json!({"aiThread": c.thread, "containerName": c.container}))
        .unwrap_or(Value::Null);
    let opts = crate::signin::Opts {
        user_confirmed_new_password: arg_bool(args, "userConfirmedNewPassword", false),
        user_authorized: arg_bool(args, "userAuthorized", false),
        window: args.get("window").and_then(|w| w.as_str().map(String::from).or_else(|| w.as_i64().map(|h| h.to_string()))),
    };
    crate::signin::sign_in(reason, opts, who)
}

/// `swx_user_presence` — the presence measurement every quit and sweep is
/// guarded by, on its own (0.9.9). Read-only towards SOLIDWORKS; positive
/// evidence marks the session taken over by the user, exactly as the guard does.
fn swx_user_presence(ctx: &Ctx, _args: &Value) -> Value {
    // EXEMPT FROM THE STARTUP GATE (0.9.11, L4): a read about the USER, needed
    // most while SOLIDWORKS is starting or wedged. It asks COM for documents
    // only when the session is verified ready; otherwise it measures without
    // COM and says the document part is unknown.
    let ready = crate::readiness::assess(ctx).get("ready").and_then(|v| v.as_bool()) == Some(true);
    let measured = if ready {
        crate::presence::measure(ctx).or_else(crate::presence::measure_without_com)
    } else {
        crate::presence::measure_without_com()
    };
    match measured {
        None => ok(json!({"present": Value::Null, "running": false,
                          "_hint": "No running SOLIDWORKS session to measure."})),
        Some((present, evidence)) => ok(json!({
            "present": present,
            "evidence": evidence,
            "_hint": if present {
                "The user is working in this SOLIDWORKS: swx_stop, swx_shutdown and every sweep \
                 refuse user_is_active. Leave the session alone; it is theirs."
            } else {
                "No sign of the user right now. This is a reading, not a promise — every verb that \
                 ends work measures again before it acts."
            },
        })),
    }
}

fn swx_describe(_ctx: &Ctx, _args: &Value) -> Value {
    let verbs: Vec<Value> = CATALOG
        .iter()
        .map(|(verb, risk, summary)| json!({"verb": verb, "summary": summary, "risk": risk}))
        .collect();
    ok(json!({
        "bridge": "solidworks",
        "keyword": "swx",
        "version": crate::BRIDGE_VERSION,
        "mock": crate::is_mock(),
        "verbs": verbs,
        "scriptOps": crate::script::OPS,
        // What each modelling op variant has been MEASURED on, live — the one
        // table step results' `proven` and swx_selftest report too.
        "liveMeasured": crate::compat::live_table(None),
        "exportFormats": crate::verbs3::export_format_note(),
        "userWrites": crate::writes::posture(),
        "writePolicy": "DENY BY DEFAULT. This bridge writes only to documents it created itself \
                        (swx_new_document / swx_import) and to destination paths that do not \
                        exist yet. Touching the user's documents, or overwriting an existing \
                        file, requires the bridge process to be started with \
                        SWX_ALLOW_USER_WRITES=1 AND the call to carry allowUserWrites:true, \
                        confirm:true and a reason. An AI cannot grant this to itself; ask the \
                        user. Every exception is recorded in swx_action_ledger.",
        "_hint": "Say 'swx' to reach this bridge. Everyday flow: swx_status → swx_find_files → \
                  swx_open → swx_bom / swx_mass_properties / swx_export. To MAKE something: \
                  swx_new_document → swx_run_script → swx_inspect_bodies → swx_save_as. \
                  swx_get_app_state is the poll-me verb, swx_state the render-me one. Verb names \
                  and arg names mirror the Fusion 360 bridge (`outputPath`, `filePath`, `name`, \
                  `parameters`, `bodies`) so fusion_* knowledge transfers directly; \
                  docs/FUSION-PARITY-REPORT.md records exactly where the two diverge and why. \
                  Every verb returns its own _hint; that is the documentation that matters.",
    }))
}

// ── dispatch ────────────────────────────────────────────────────────────────

/// THE VERBS THAT DO NOT WAIT FOR SOLIDWORKS TO FINISH STARTING. Every other
/// verb is GATED BY DEFAULT (adversary pass 42: a fifteen-name gate list left
/// swx_show_*, the export verbs and swx_com_eval — the crash log's own
/// `CMD:DocSave` — reachable during add-in load). A new verb is gated until it
/// is added here, and `tests/wiring.rs` pins this list name by name. Each one is
/// here because it never reaches the com host, or must work on a start that is
/// wedged:
pub const STARTUP_EXEMPT: &[(&str, &str)] = &[
    ("swx_status", "the probe that reports readiness itself"),
    ("swx_readiness", "process detection only; never reaches the com host"),
    ("swx_start", "launches and attaches; reports `stage: starting|ready`"),
    ("swx_describe", "the catalog; bridge-local"),
    ("swx_progress", "the bridge's own job table"),
    ("swx_action_ledger", "the bridge's own ledger file"),
    ("swx_verb_times", "the bridge's own timing file"),
    ("swx_uninstall", "the bridge's own artifacts on disk"),
    ("swx_find_files", "searches the disk; works without SOLIDWORKS"),
    ("swx_recover_list", "reads the recovery folders on disk"),
    ("swx_window_info", "a window census; no COM — a startup dialog is what it is for"),
    ("swx_dismiss_blocking_dialogs", "a window census (close is opt-in); no COM"),
    ("swx_close_window", "one window message by hwnd (reason + confirm); no COM"),
    ("swx_screenshot", "through ab's generic screenshot; no com host"),
    ("swx_set_driving_badge", "the taskbar badge; no com host"),
    ("swx_kill", "the last resort for a wedged start (reason + confirm); reads the document count only to say what is lost"),
    ("swx_sign_in", "acts on the LAUNCHER's sign-in window, never through COM — it is how a start gets past the sign-in at all"),
    ("swx_user_presence", "a read about the USER, needed most during a start; asks COM for documents only on a verified-ready session"),
    ("swx_selftest", "never reaches the com host itself: every check it runs goes through this same gate, and a check held at it is REPORTED — a self-test that refused to run while SOLIDWORKS starts could not say so"),
    ("swx_com_respawn", "respawns the bridge's own com host child and never calls SOLIDWORKS — the readiness gate would"),
];

/// swx_stop is gated with one carve-out: under `userAuthorized`, a plan that
/// reaches the WINDOW-MESSAGE rungs (c, d) runs during startup with the COM rungs
/// (a, b, e) skipped — those two need neither COM nor readiness, and a start
/// wedged behind a dialog is exactly when a quit needs them.
pub fn is_startup_exempt(command: &str) -> bool {
    STARTUP_EXEMPT.iter().any(|(v, _)| *v == command)
}

/// Read or mutate, for the gate: catalog risk `read` reads; `exec`, `write` and
/// `process` mutate.
pub fn startup_touch(command: &str) -> crate::readiness::Touch {
    match CATALOG.iter().find(|(v, _, _)| *v == command).map(|(_, r, _)| *r) {
        Some("read") => crate::readiness::Touch::Read,
        _ => crate::readiness::Touch::Mutate,
    }
}

/// swx_stop's carve-out from the startup gate: `Some(args)` (marked so the
/// ladder skips its COM rungs) when the user authorized the quit and the plan
/// reaches a window-message rung.
fn stop_window_rungs_during_startup(command: &str, args: &Value) -> Option<Value> {
    if command != "swx_stop" || !arg_bool(args, "userAuthorized", false) {
        return None;
    }
    let max = args.get("maxRung").and_then(|v| v.as_str()).unwrap_or("a");
    if !crate::quit::rungs_up_to(max).iter().any(|r| matches!(*r, "c" | "d")) {
        return None;
    }
    let mut a = args.clone();
    if let Value::Object(m) = &mut a {
        m.insert("_startupNotVerified".into(), json!(true));
    }
    Some(a)
}

pub fn is_known(command: &str) -> bool {
    CATALOG.iter().any(|(v, _, _)| *v == command)
}

/// Route one verb. Unknown commands answer with the full valid list — a caller
/// that guessed a name gets the surface back instead of a dead end.
pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value {
    // Test-only fault injection (debug builds only, never in the shipped exe):
    // lets the suite prove a handler panic is caught by the HTTP worker and
    // turned into JSON, with the worker still serving afterwards.
    #[cfg(debug_assertions)]
    if std::env::var("SWX_TEST_PANIC").ok().as_deref() == Some(command) {
        panic!("test-induced panic in {command}");
    }

    // NOT BEFORE SOLIDWORKS HAS FINISHED STARTING. Every verb not on
    // STARTUP_EXEMPT waits for readiness: measured 2026-10-01, document commands
    // sent 8 s after COM attach crashed SOLIDWORKS while its 3DEXPERIENCE add-in
    // was still initialising. Reads may run on an unverifiable session after the
    // grace; mutations never do. See src/readiness.rs.
    // INTERNAL ARGUMENTS ARE NOT CALLER INPUT (adversary pass 44, F5). Every
    // underscore-prefixed key is the dispatcher's own (`_startupNotVerified`
    // skips the gentle COM quit rungs); a caller who passes one has it removed
    // here, before the gate and the handler see it.
    let stripped: Value;
    let args: &Value = match args {
        Value::Object(m) if m.keys().any(|k| k.starts_with('_')) => {
            stripped = Value::Object(m.iter().filter(|(k, _)| !k.starts_with('_')).map(|(k, v)| (k.clone(), v.clone())).collect());
            &stripped
        }
        _ => args,
    };
    let mut gated_args: Option<Value> = None;
    // WHAT THE GATE SAW goes into every gated verb's reply, success or refusal
    // (0.9.6, G3), so a live run can prove no COM verb ran while starting.
    let mut at_gate: Option<Value> = None;
    // BEFORE the handler table: several arms return directly (swx_show_*, the
    // export verbs), and a gate placed after the table never saw them.
    if is_known(command) && !is_startup_exempt(command) {
        match crate::readiness::gate(ctx, command, startup_touch(command)) {
            Ok(summary) => at_gate = Some(summary),
            Err(refusal) => match stop_window_rungs_during_startup(command, args) {
                Some(a) => {
                    let mut summary =
                        crate::readiness::gate_summary(refusal.get("readiness").unwrap_or(&Value::Null));
                    if let Value::Object(m) = &mut summary {
                        m.insert("gatePassedFor".into(), json!("windowMessageRungs"));
                    }
                    at_gate = Some(summary);
                    gated_args = Some(a);
                }
                None => {
                    let mut refusal = refusal;
                    if command == "swx_stop" {
                        if let Value::Object(m) = &mut refusal {
                            m.insert(
                                "windowMessageRungs".into(),
                                json!("Under userAuthorized:true with maxRung \"c\" or \"d\", \
                                       swx_stop runs during startup with the COM rungs (a, b, e) \
                                       skipped: the window-message rungs need neither COM nor a \
                                       finished start."),
                            );
                        }
                    }
                    return refusal;
                }
            },
        }
    }
    let args: &Value = gated_args.as_ref().unwrap_or(args);
    // THE USER-PRESENCE GUARD (0.9.9): a verb that ends work never runs on a
    // session the user is working in — measured, fail-closed. See
    // src/presence.rs.
    let mut presence: Option<Value> = None;
    // A RENDER THAT CHANGES THE DISPLAY (orientation, style, RealView, AO,
    // shadows, perspective, background, zoom) is guarded like a sweep; an
    // export of the CURRENT view changes nothing on screen and is not (0.9.18).
    let render_touches_screen = command == "swx_render_image" && crate::render::changes_display(args);
    if crate::presence::GUARDED.contains(&command) || render_touches_screen {
        match crate::presence::guard(ctx, command, args) {
            Ok(evidence) => presence = Some(evidence),
            Err(refusal) => {
                let mut refusal = refusal;
                if let (Some(summary), Value::Object(m)) = (at_gate.clone(), &mut refusal) {
                    m.entry("readiness").or_insert(summary);
                }
                return refusal;
            }
        }
    }
    // THE MEMORY GUARD (0.10.5): a verb that creates or loads geometry is
    // refused while committed memory is CRITICAL — measured 2026-10-14, the
    // SOLIDWORKS Resource Monitor warned at 94 % committed — and the refusal
    // changes nothing. `ignoreMemoryPressure:true` with a reason overrides, and
    // the override is ledgered. See src/memory.rs.
    let mut memory_override: Option<Value> = None;
    if crate::memory::guarded(command, args) {
        match crate::memory::guard(ctx, command, args) {
            Ok(o) => memory_override = o,
            Err(refusal) => {
                let mut refusal = refusal;
                if let (Some(summary), Value::Object(m)) = (at_gate.clone(), &mut refusal) {
                    m.entry("readiness").or_insert(summary);
                }
                return refusal;
            }
        }
    }
    // THE OTHER HALF OF THE PRESENCE RULE (0.10.9): the bridge does not POP
    // ITS OWN WINDOWS on a user who is working. A verb creating a document for
    // the bridge's own purposes waits for the user to go idle, then refuses
    // `user_present`, having created nothing. A user's own request is allowed
    // and its reply says the user is active.
    if crate::presence::creates_for_bridge(command, args) {
        let wait = crate::presence::wait_for_idle_secs(args);
        if let Err(a) = crate::presence::wait_for_idle(wait) {
            let mut refusal = crate::presence::user_present_refusal(command, wait, &a);
            if let (Some(summary), Value::Object(m)) = (at_gate.clone(), &mut refusal) {
                m.entry("readiness").or_insert(summary);
            }
            return refusal;
        }
    }
    let changes_documents = is_known(command)
        && startup_touch(command) == crate::readiness::Touch::Mutate
        && !is_startup_exempt(command);
    // WHAT WAS OPEN BEFORE THE VERB — so the baseline grows only by what the
    // verb itself opened or created (0.9.10, adversary pass 46).
    let before = if changes_documents { crate::presence::snapshot(ctx) } else { None };
    // EVERY VERB IS BRACKETED BY A DIALOG CENSUS (0.11.1): a SOLIDWORKS dialog
    // that appears while it runs is attributed to it (`dialogs::observe`).
    let bracket = (is_known(command) && crate::dialogs::brackets(command)).then(|| crate::dialogs::begin(command));
    let mut out = dispatch_after_gate(ctx, command, args);
    if let Some(id) = bracket {
        let fresh = crate::dialogs::end(id);
        if !fresh.is_empty() {
            if let Value::Object(m) = &mut out {
                m.insert("dialogsAppeared".into(), json!(fresh));
            }
        }
    }
    if let (Some(summary), Value::Object(m)) = (at_gate, &mut out) {
        m.entry("readiness").or_insert(summary);
    }
    if let (Some(evidence), Value::Object(m)) = (presence, &mut out) {
        m.entry("userPresence").or_insert(evidence);
    }
    if let (Some(o), Value::Object(m)) = (memory_override, &mut out) {
        m.entry("memoryOverride").or_insert(o);
    }
    // A USER'S OWN document request, while they are active: allowed, and said.
    if matches!(command, "swx_new_document" | "swx_run_script" | "swx_import")
        && !crate::presence::creates_for_bridge(command, args)
    {
        let a = crate::presence::activity();
        if a.active {
            if let Value::Object(m) = &mut out {
                m.entry("userActive").or_insert(json!(true));
                m.entry("userPresence").or_insert(a.to_json());
            }
        }
    }
    if changes_documents {
        // A verb that adds documents on a session the user has taken over is
        // allowed — it adds, never removes — and says so.
        if crate::session_history::ADDS_DOCUMENTS.contains(&command) {
            if let Some(t) = crate::presence::taken_over_now(ctx) {
                if let Value::Object(m) = &mut out {
                    m.insert("userPresent".into(), t);
                }
            }
        }
        // WHAT THIS VERB OPENED OR CREATED becomes the bridge's; nothing else
        // does. A document that appears between two bridge verbs stays the
        // user's.
        crate::presence::note_bridge_changed(ctx, before);
    }
    // EVERY REPLY, while memory is pressing, names the largest holders (0.10.5).
    crate::memory::annotate(&mut out);
    out
}

/// The handler table and the post-processing, after the startup gate.
fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {
    let handler: fn(&Ctx, &Value) -> Value = match command {
        "swx_status" => swx_status,
        "swx_readiness" => swx_readiness,
        "swx_describe" => swx_describe,
        "swx_state" => swx_state,
        "swx_progress" => swx_progress,
        "swx_start" => swx_start,
        "swx_stop" => swx_stop,
        "swx_com_respawn" => swx_com_respawn,
        // DREW'S STANDING AUTHORIZATION, 2026-09-11: the bridge closes documents
        // and quits by itself, having first written every document to disk.
        "swx_close_all_safely" => crate::verbs3::close_all_safely,
        "swx_shutdown" => crate::verbs3::shutdown,
        "swx_recover_list" => crate::verbs3::recover_list,
        "swx_user_presence" => swx_user_presence,
        "swx_sign_in" => swx_sign_in,
        "swx_docs" => swx_docs,
        "swx_find_files" => swx_find_files,
        "swx_open" => swx_open,
        "swx_close_doc" => swx_close_doc,
        "swx_save" => swx_save,
        "swx_export" => swx_export,
        "swx_pack_and_go" => swx_pack_and_go,
        "swx_props_get" => swx_props_get,
        "swx_props_set" => swx_props_set,
        "swx_bom" => swx_bom,
        "swx_mass_properties" => swx_mass_properties,
        "swx_interference" => swx_interference,
        "swx_rebuild_check" => swx_rebuild_check,
        "swx_screenshot" => swx_screenshot,
        "swx_com_eval" => swx_com_eval,
        "swx_uninstall" => swx_uninstall,

        // v0.3
        "swx_new_document" => crate::verbs3::new_document,
        "swx_import" => crate::verbs3::import,
        "swx_save_as" => crate::verbs3::save_as,
        "swx_get_app_state" => crate::verbs3::get_app_state,
        "swx_document_info" => crate::verbs3::document_info,
        "swx_activate_document" => crate::verbs3::activate_document,
        "swx_close_all_documents" => crate::verbs3::close_all_documents,
        "swx_reset_workspace" => crate::verbs3::reset_workspace,
        "swx_close_import_temps" => crate::verbs3::close_import_temps,
        "swx_kill" => crate::verbs3::kill,
        "swx_window_info" => crate::verbs3::window_info,
        "swx_dismiss_blocking_dialogs" => crate::verbs3::dismiss_blocking_dialogs,
        "swx_close_window" => crate::verbs3::close_window,
        "swx_check_recovery" => crate::verbs3::check_recovery,
        "swx_action_ledger" => crate::verbs3::action_ledger,
        "swx_verb_times" => crate::verbs3::verb_times,
        "swx_set_driving_badge" => crate::verbs3::set_driving_badge,
        "swx_design_info" => crate::verbs3::design_info,
        "swx_get_parameters" => crate::verbs3::get_parameters,
        "swx_set_parameter" => crate::verbs3::set_parameter,
        "swx_inspect_bodies" => crate::verbs3::inspect_bodies,
        "swx_run_macro" => crate::verbs3::run_macro,
        "swx_run_script" => crate::verbs3::run_script,
        "swx_selftest" => crate::selftest::selftest,
        "swx_add_component" => crate::verbs3::add_component,
        "swx_get_preferences" => crate::verbs3::get_preferences,
        "swx_set_preference" => crate::verbs3::set_preference,
        "swx_cloud_status" => crate::verbs3::cloud_status,
        "swx_show_part" => return crate::verbs3::show(ctx, args, "part"),
        "swx_show_assembly" => return crate::verbs3::show(ctx, args, "assembly"),
        "swx_show_drawing" => return crate::verbs3::show(ctx, args, "drawing"),
        "swx_export_step" => return crate::verbs3::export_as(ctx, args, "step"),
        "swx_export_iges" => return crate::verbs3::export_as(ctx, args, "iges"),
        "swx_export_stl" => return crate::verbs3::export_as(ctx, args, "stl"),
        "swx_export_3mf" => return crate::verbs3::export_as(ctx, args, "3mf"),
        "swx_export_glb" => return crate::verbs3::export_as(ctx, args, "glb"),
        "swx_export_sat" => return crate::verbs3::export_as(ctx, args, "sat"),
        "swx_export_parasolid" => return crate::verbs3::export_as(ctx, args, "parasolid"),
        "swx_export_pdf" => return crate::verbs3::export_as(ctx, args, "pdf"),
        "swx_export_dxf" => return crate::verbs3::export_as(ctx, args, "dxf"),
        "swx_export_dwg" => return crate::verbs3::export_as(ctx, args, "dwg"),
        "swx_render_image" => crate::render::render_image,

        other => {
            let valid: Vec<&str> = CATALOG.iter().map(|(v, _, _)| *v).collect();
            return json!({
                "success": false,
                "error": format!("Unknown command: {other}"),
                "_hint": format!("Valid swx commands: {}.", valid.join(", ")),
                "_reportIssues": report_issues(),
            });
        }
    };

    let mut out = handler(ctx, args);
    // A SESSION'S HISTORY: whether it has ever held a document decides how the
    // quit ladder treats a declined ExitApp (see src/session_history.rs).
    crate::session_history::note_reply(ctx, command, &out);
    // UNATTENDED SIGN-IN, ARMED (0.9.13): `signIn:"auto"` — the default is "wait".
    if command == "swx_start" && arg_str(args, "signIn") == Some("auto") {
        crate::launch::arm_auto_sign_in(crate::launchos::os().as_ref());
        if let Value::Object(m) = &mut out {
            m.insert("signInMode".into(), json!("auto"));
        }
    }
    // BLOCKED ON THE USER'S SIGN-IN (0.9.12): said plainly, with the window.
    // 0.9.23: `signInWindow` is an OBJECT {hwnd, title, ownerImage, host, …},
    // and ANY 3DEXPERIENCE ID login on the machine counts — by class/title or by
    // CONTENT (measured 2026-10-12: the SWXDesktopUpdate-hosted login, titled
    // just "3DEXPERIENCE", was up while swx_status said waitingOn:null).
    if command == "swx_start" || command == "swx_status" {
        let os = crate::launchos::os();
        let attempt = crate::launch::attempt();
        // From a LIVE census whenever the record names the sign-in stage —
        // the monitor that first saw the window may have died with an older
        // bridge process (0.9.13).
        let live = match &attempt {
            Some(a) if a.stage.name() == "helperSignIn" => match crate::launch::sign_in_now(os.as_ref()) {
                Some(Some((h, t))) => Some((Some(h), t)),
                _ => None,
            },
            _ => None,
        };
        let recorded = attempt.as_ref().and_then(|a| a.sign_in_window.clone().filter(|_| !a.finished)).map(|t| (None, t));
        let machine = crate::signin::login_windows(os.as_ref(), true).unwrap_or_default();
        let ours_title = live.or(recorded);
        let window: Option<Value> = match &ours_title {
            Some((h, t)) => Some(
                machine
                    .iter()
                    .find(|l| Some(l.hwnd) == *h || (h.is_none() && l.title == *t))
                    .map(|l| l.to_json())
                    .unwrap_or_else(|| json!({"hwnd": h, "title": t, "ownerImage": Value::Null, "host": Value::Null})),
            ),
            None => machine.iter().find(|l| l.ours).or_else(|| machine.first()).map(|l| l.to_json()),
        };
        if let Some(w) = window {
            if let Value::Object(m) = &mut out {
                if let Some(a) = attempt.as_ref().filter(|_| ours_title.is_some()) {
                    m.insert("signInInventory".into(), a.sign_in_inventory.clone().unwrap_or(Value::Null));
                    m.insert("signInMode".into(), json!(if a.sign_in_auto { "auto" } else { "wait" }));
                }
                m.insert("waitingOn".into(), json!("userSignIn"));
                m.insert("signInWindow".into(), w);
                if machine.len() > 1 {
                    m.insert("loginWindows".into(), json!(machine.iter().map(|l| l.to_json()).collect::<Vec<_>>()));
                }
                // swx_start must not say "launching" while the start is parked
                // behind a login (0.9.23).
                if command == "swx_start" && m.get("stage").and_then(|v| v.as_str()) == Some("launching") {
                    m.insert("stage".into(), json!("waitingOn"));
                }
                m.insert(
                    "_signInHint".into(),
                    json!("A 3DEXPERIENCE sign-in window is waiting for the user (signInWindow). A launch's \
                           deadlines are paused and nothing is killed. swx_sign_in {reason} (a launch this bridge \
                           started) or swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} (on the \
                           user's say-so) PREPARES the page: it fills the user name from the vault's host list and \
                           moves to the password page. ab's policy keeps the saved password with the user \
                           (measured: a bridge is not a verified local caller), so the USER types it until ab \
                           issue #237 (a fill verb that keeps the secret inside ab) lands. Then poll swx_status."),
                );
            }
        }
    }
    // `attached` IS NOT `ready`: swx_start and swx_status say which, and on what.
    if command == "swx_start" || command == "swx_status" {
        let attached = out.get("attached").and_then(|v| v.as_bool()) == Some(true)
            || out.get("stage").and_then(|v| v.as_str()) == Some("attached")
            || out.pointer("/com/running").and_then(|v| v.as_bool()) == Some(true);
        if attached && out.get("success").and_then(|v| v.as_bool()) != Some(false) {
            let r = crate::readiness::assess(ctx);
            let ready = r.get("ready").and_then(|v| v.as_bool());
            if let Value::Object(map) = &mut out {
                if command == "swx_start" {
                    match ready {
                        Some(true) => {
                            map.insert("stage".into(), json!("ready"));
                        }
                        Some(false) => {
                            map.insert("stage".into(), json!("starting"));
                            map.insert("stillStarting".into(), json!(true));
                            map.insert(
                                "_readyHint".into(),
                                json!("COM is attached but SOLIDWORKS has not finished starting. \
                                       Document verbs refuse with host_app_starting until it has: \
                                       poll swx_status until `solidworks.ready` is true."),
                            );
                        }
                        None => {}
                    }
                    map.insert("ready".into(), json!(ready));
                    map.insert("readiness".into(), r);
                } else if let Some(Value::Object(sw)) = map.get_mut("solidworks") {
                    // THE SESSION'S DOCUMENT HISTORY, so a caller can predict a
                    // priming (0.9.5).
                    let history = r
                        .get("processId")
                        .and_then(|v| v.as_u64())
                        .map(|pid| {
                            // The creation time readiness resolved (census, else
                            // the host's own word) — the same key the poll marked.
                            let created = r.get("processCreatedAt").and_then(|v| v.as_u64());
                            crate::session_history::describe(pid as u32, created)
                        })
                        .unwrap_or(Value::Null);
                    sw.insert("ready".into(), json!(ready));
                    sw.insert("readiness".into(), r);
                    sw.insert("sessionHistory".into(), history);
                }
            }
        }
    }
    // A failure always tells the caller how to report it — real machines are
    // the only place the COM layer can actually be proven.
    if out.get("success").and_then(|v| v.as_bool()) == Some(false) {
        if let Value::Object(map) = &mut out {
            map.entry("_reportIssues").or_insert_with(|| Value::from(report_issues()));
        }
    }
    out
}

#[cfg(test)]
mod tests {
    #[test]
    fn titles_compare_without_the_solidworks_extension() {
        use super::{same_title, title_stem};
        // MEASURED 2026-10-15 (Michael, SOLIDWORKS 2021, HideFileExt=1).
        assert!(same_title("Part14-1792088642002.SLDPRT", "Part14-1792088642002"));
        assert!(same_title("Part14-1792088642002", "Part14-1792088642002.SLDPRT"));
        assert!(same_title("gearbox.sldasm", "gearbox.SLDASM"), "the extension's case does not matter");
        assert!(same_title("sheet.SldDrw", "sheet"));
        assert!(!same_title("Part1", "part1"), "the NAME is compared exactly");
        assert!(!same_title("Part1", "Part12"));
        assert!(!same_title("", ""), "an empty title names nothing");
        // A dot elsewhere is part of the name, never an extension.
        assert_eq!(title_stem("Bracket Rev1.2"), "Bracket Rev1.2");
        assert_eq!(title_stem("Bracket Rev1.2.SLDPRT"), "Bracket Rev1.2");
        assert!(!same_title("Bracket Rev1.2", "Bracket Rev1"));
        assert_eq!(title_stem("drawing.pdf"), "drawing.pdf", "only SOLIDWORKS' own extensions");
        assert_eq!(title_stem(".SLDPRT"), ".SLDPRT", "an extension alone is not stripped to nothing");
        // Ownership of an unsaved document follows the same rule.
        assert!(super::doc_matches("Part3.SLDPRT", None, "Part3", None));
    }

    use super::*;

    #[test]
    fn every_catalog_verb_is_unique_prefixed_and_dispatchable() {
        let mut names: Vec<&str> = CATALOG.iter().map(|(v, _, _)| *v).collect();
        let total = names.len();
        assert!(total >= 60, "v0.3 ships the Fusion-parity surface, got {total}");
        names.sort();
        names.dedup();
        assert_eq!(names.len(), total, "duplicate verb in the catalog");
        for (v, _, _) in CATALOG {
            assert!(is_known(v));
            assert!(v.starts_with("swx_"), "{v} must carry the bridge's prefix");
        }
    }

    #[test]
    fn export_extensions_cover_the_documented_formats() {
        for f in ["step", "STP", ".iges", "stl", "pdf", "dxf", "dwg", "png", "jpg", "parasolid"] {
            assert!(export_extension(f).is_some(), "{f}");
        }
        assert_eq!(export_extension("step"), Some(".step"));
        assert_eq!(export_extension("parasolid"), Some(".x_t"));
        assert_eq!(export_extension("wat"), None);
    }

    #[test]
    fn the_host_gate_only_blocks_a_real_machine_without_solidworks() {
        assert!(gate_for(true, false).is_none(), "mock mode serves every verb");
        assert!(gate_for(false, true).is_none(), "an installed host app is not gated");
        let blocked = gate_for(false, false).expect("no SOLIDWORKS, no mock => gated");
        assert_eq!(blocked["success"], false);
        assert_eq!(blocked["errorCode"], "host_app_not_installed");
        let hint = blocked["_hint"].as_str().unwrap();
        assert!(hint.contains("Hawk Ridge"), "the hint must carry the current install story");
        assert!(
            hint.contains("Do NOT tell the user to install it by hand"),
            "the hint must stop the AI inventing an install path"
        );
    }

    #[test]
    fn safe_out_allows_absolute_and_refuses_traversal() {
        assert!(safe_out("/home/u/part.step").is_ok());
        assert!(safe_out(r"C:\exports\part.step").is_ok());
        assert!(safe_out("C:/exports/part.step").is_ok());
        assert!(safe_out(r"\\server\share\part.step").is_ok());
        // relative → rejected (would resolve against the bridge cwd)
        assert!(safe_out("part.step").is_err());
        assert!(safe_out("../../victim.txt").is_err());
        // drive-RELATIVE (colon, no separator) → rejected: C:foo resolves against
        // the cwd on drive C, it is not rooted.
        assert!(safe_out("C:foo.step").is_err());
        assert!(safe_out("C:..\\victim").is_err());
        // absolute-but-with-.. → rejected
        assert!(safe_out("/home/u/../../etc/passwd").is_err());
    }

    #[test]
    fn only_a_launchable_edition_gets_past_may_launch() {
        let desktop = Detection {
            installed: true,
            shortcut: None,
            exe: Some(r"C:\Program Files\SOLIDWORKS Corp\SOLIDWORKS\SLDWORKS.exe".into()),
            registry_versions: vec![],
            running: false,
            launcher_present: false,
        };
        let connected = Detection {
            exe: Some(r"C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\SLDWORKS.exe".into()),
            ..desktop.clone()
        };
        let unknown = Detection { exe: Some(r"D:\odd\SLDWORKS.exe".into()), ..desktop.clone() };
        assert!(may_launch(false, &desktop));
        assert!(!may_launch(false, &connected), "Connected editions refuse a programmatic launch");
        assert!(!may_launch(false, &unknown), "an unrecognised layout must fail safe");
        assert!(may_launch(true, &connected), "the mock app is launchable so the suite can drive it");
    }

    #[test]
    fn the_scratch_registry_is_scoped_to_one_solidworks_session() {
        let ctx = Ctx::new(Detection::default());
        let s1 = Some(4242i64); // one SOLIDWORKS session
        let s2 = Some(9999i64); // ...and the one after it restarted
        assert!(ctx.scratch_titles().is_empty(), "a fresh process owns nothing");

        ctx.remember_scratch("Part1", s1);
        ctx.remember_scratch("Part1", s1); // idempotent
        ctx.remember_scratch("Assem1", s1);
        ctx.remember_scratch("", s1); // an empty title is not a document
        assert_eq!(ctx.scratch_titles(), vec!["Part1".to_string(), "Assem1".to_string()]);
        assert!(ctx.owns_doc("Part1", None, s1));

        // THE bug this exists to stop: SOLIDWORKS restarts its Part numbering
        // every session, so a remembered "Part1" would otherwise match a fresh
        // "Part1" the USER made, and the bridge would treat their document as
        // its own.
        assert!(!ctx.owns_doc("Part1", None, s2), "a title from another session is NOT ours");
        // ...and with no session at all we cannot tell the difference, so: no.
        assert!(!ctx.owns_doc("Part1", None, None), "an unknown session must fail CLOSED");

        // Pruning drops entries from another session, and entries for documents
        // that are no longer open — which is what covers a close in the
        // SOLIDWORKS UI, a crash, or CloseAllDocuments, none of which run
        // through this bridge.
        let open = |t: &str| vec![(t.to_string(), None)];
        ctx.prune_scratch(s1, &open("Part1"));
        assert_eq!(ctx.scratch_titles(), vec!["Part1".to_string()], "Assem1 is no longer open");
        ctx.prune_scratch(s2, &open("Part1"));
        assert!(ctx.scratch_titles().is_empty(), "a new session invalidates everything");

        // An EMPTY path is no path. The live run turned on exactly this
        // distinction: SOLIDWORKS reports "" for an unsaved document, and a
        // `Some("")` where `None` was expected made it unaddressable.
        assert!(
            !ctx.owns_doc("Part1", Some(""), s1) || ctx.owns_doc("Part1", None, s1),
            "an empty path must be treated as no path"
        );
        ctx.remember_scratch("PartX", s1);
        assert!(ctx.owns_doc("PartX", Some(""), s1), "empty path => fall back to the title");
        assert!(ctx.owns_doc("PartX", None, s1), "...same as no path at all");
        ctx.forget_scratch("PartX");

        // A save_as retitles a document; our claim on it must survive, and it
        // becomes a claim on the PATH.
        ctx.remember_scratch("Part2", s1);
        ctx.rename_scratch("Part2", "bracket.SLDPRT", Some("C:\\p\\bracket.sldprt".into()));
        assert!(ctx.owns_doc("bracket.SLDPRT", Some("C:\\p\\bracket.sldprt"), s1));
        assert!(!ctx.owns_doc("Part2", None, s1));

        // THE title-collision case. Once a document of ours has a path, a
        // DIFFERENT document that merely shares its title is not ours — two
        // folders each holding a `bracket.SLDPRT` is an ordinary thing to have
        // open, and treating the title as the identity is how a save_as once
        // renamed the user's document instead of the bridge's.
        assert!(
            !ctx.owns_doc("bracket.SLDPRT", Some("C:\\users\\bracket.sldprt"), s1),
            "same title, different path => NOT ours"
        );
        assert!(
            !ctx.owns_doc("bracket.SLDPRT", None, s1),
            "an unsaved document is never matched by a claim on a saved one"
        );

        ctx.forget_scratch("bracket.SLDPRT");
        assert!(ctx.scratch_titles().is_empty());
        ctx.forget_scratch("SomeoneElsesPart1"); // a no-op, not a panic

        // 0.9.29 AUDIT: forgetting / renaming ONE document must not touch
        // another owned document that merely shares its title.
        ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\a\\bracket.sldprt");
        ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\b\\bracket.sldprt");
        ctx.forget_scratch_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"));
        assert!(!ctx.owns_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"), s1), "the closed one is forgotten");
        assert!(ctx.owns_doc("bracket.SLDPRT", Some("C:\\b\\bracket.sldprt"), s1), "the OTHER one is still ours");
        ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\a\\bracket.sldprt");
        ctx.rename_scratch_doc("bracket.SLDPRT", Some("C:\\b\\bracket.sldprt"), "b2.SLDPRT", Some("C:\\b\\b2.sldprt".into()), true);
        assert!(ctx.owns_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"), s1), "the one NOT saved keeps its identity");
        assert!(ctx.owns_doc("b2.SLDPRT", Some("C:\\b\\b2.sldprt"), s1), "the saved one moved with its path");
        ctx.forget_scratch_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"));
        ctx.forget_scratch_doc("b2.SLDPRT", Some("C:\\b\\b2.sldprt"));
        assert!(ctx.scratch_titles().is_empty());
    }

    #[test]
    fn ok_merge_lets_extras_win_over_host_result() {
        let merged = ok_merge(json!({"a": 1, "b": 2}), json!({"b": 3}));
        assert_eq!(merged["success"], true);
        assert_eq!(merged["a"], 1);
        assert_eq!(merged["b"], 3);
    }
}