main
Drew Owens 0.9.9 source: user-presence guard; adversary pass 45 (unreadable log unverifiable, latch keyed by pid+creation); manife… e772fef 19d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974
#!/usr/bin/env python3
"""quit-gate.py — prove the graceful QUIT ladder on the REAL machine.

RUN THIS ONLY WHEN DREW SAYS SOLIDWORKS IS FREE, AND ONLY ON A SESSION WITH
**ZERO DOCUMENTS OPEN**. It ENDS that SOLIDWORKS session. On a Connected
edition, starting it again takes 1-3 minutes and can fail — a quit is close to a
one-way door, which is why this gate exists at all instead of the ladder simply
shipping enabled.

The protocol is docs/QUIT-INVESTIGATION.md §5.7. What is different about this
gate, versus tools/launch-gate.py, is what it is allowed to do:

    READ-ONLY, everything, with exactly ONE exception: the `swx_stop` call in
    step 2. There is no kill anywhere in this file, no window is closed, no
    dialog is answered, no file outside %TEMP% is written, and no SOLIDWORKS
    process is ever named to a terminate API. If a rung leaves a dialog on the
    user's screen the gate STOPS and says so — it does not clear the screen.
    Grep this file for taskkill / Stop-Process / CloseMainWindow / Terminate:
    this sentence is the ONLY place any of those words appears, and that
    absence is the whole safety argument.

WHAT IT MEASURES, and why it measures instead of reading a return value
──────────────────────────────────────────────────────────────────────
v1 of `swx_stop` called `ExitApp`, asked the COM running-object table whether
SOLIDWORKS was still there, and reported `success:true, stopped:false` — a
DECLINE dressed as a success. Every claim below is therefore taken from the
process census (pid + creation time), the window census, and the launcher's own
files. The bridge's own answer is recorded beside each one and never trusted:
where they disagree, the machine wins and the gate says so.

    0. BASELINE     the §0.2 process set with pids AND creation times; the
                    window census for the SOLIDWORKS pid (class, visible,
                    IsHungAppWindow, GetMenuState(SC_CLOSE)); the journal size;
                    %TEMP%\\swx* ; the ledger tail. Read-only, and printed in
                    full so the run is auditable afterwards.
    1. ZERO DOCS    documentCount == 0 AND unsavedDocuments == 0 AND the count
                    AGREES with the enumerated list (a truncated enumeration is
                    UNKNOWN, and UNKNOWN aborts). This is the step that makes
                    the whole run safe; nothing proceeds past a doubt here.
    2. RUNG         one swx_stop, then the full measurement set at +2/+5/+10/+30s.
    3. AFTERMATH    children reaped, expected survivors alive, no crash
                    reporter, journal closed, and — the operational question —
                    whether SOLIDWORKS still has a window on screen.
    4. RELAUNCH     swx_start, with the launch-machine timings, because a quit
                    that costs the next launch is not a graceful quit.

    tools/quit-gate.py --baseline        # step 0 + step 1 ONLY. Reads. Ends nothing.
    tools/quit-gate.py --rung a          # baseline, then ONE rung, then stop
    tools/quit-gate.py                   # the ladder, pinned at the bridge's default
    tools/quit-gate.py --rung f --allow-restart-manager
                                         # the Restart Manager rung, opt-in (see below)
    tools/quit-gate.py --no-relaunch     # skip step 4

Rung (f) asks the OS to shut the application down, and the OS's own semantics
are "save WITHOUT prompting" — the one rung whose failure mode is a silent write
rather than a visible dialog. It needs both --rung f and
--allow-restart-manager, and the bridge needs `allowRestartManager:true` on the
call; neither this gate nor the bridge ever passes RmForceShutdown.

EXIT CODES
    0  every claim held
    1  a claim did not hold (the ladder, or the machine, disagreed)
    2  refused to run — the human preconditions were not met (documents open,
       not bridge-launched, a dialog on screen). Nothing was attempted.
    3  a probe could not be READ. Not a pass, not a failure: unproven.
"""
import argparse
import base64
import json
import re
import subprocess
import sys
import time

# THE ROUTE: verbs go through adom-bridge's verb routing to the bridge it hosts
# (see `verb`); SWX_BRIDGE_URL forces a loopback /command URL instead. The old
# default, http://127.0.0.1:24695/command, is dead since adom-bridge 2.1.163.
THREAD = "SolidWorks Bridge"
PS_FAST_EMPTY_RETRIES = 3
PS_RETRY_BACKOFF_SECS = 2

# The per-session servers a graceful exit should reap (QUIT-INVESTIGATION §0.2).
SESSION_CHILDREN = ("sldProcMon.exe", "sldsurfacing.exe", "sldimport.exe", "swCefSubProc.exe")
# Documented to outlive SOLIDWORKS. Their presence is CONSENT, not a leak, and
# nothing in this repo may ever end one.
EXPECTED_SURVIVORS = (
    "ENOPLMCSAClient.exe", "EdmServerV6.exe", "SWXDesktopHelper.exe", "sldBgDwld.exe",
    "3DEXPERIENCELauncher.exe", "3DEXPERIENCELauncherSysTray.exe", "3DEXPERIENCELauncherService.exe",
)
# Undocumented either way: RECORD it, conclude nothing.
UNJUDGED = ("ViewServerV6.exe",)

SETTLE = (2, 5, 10, 30)
# The launcher's own import-temp tail: `…\swx<pid>\IC~~\<file>`. Used ONLY to
# describe what is open; nothing in this gate closes a document.
TEMP_TAIL = re.compile(r"\\swx\d+\\IC~~\\[^\\]+$", re.IGNORECASE)
RELAUNCH_BUDGET = 300
POLL = 5


class Unreadable(Exception):
    """A probe could not be ANSWERED — not an empty answer, no answer.

    The defect this whole bridge keeps being rewritten to stop making is the
    empty-to-confident collapse: a failed read reported as "nothing found". A
    gate is the worst possible place for it, because "no SOLIDWORKS in the
    census" is exactly what SUCCESS looks like here. So an unreadable census can
    never be allowed to read as a quit.
    """


class Refused(Exception):
    """The human preconditions were not met. Nothing was attempted."""


# EVERY SCRIPT SAYS WHY IT FAILED (2026-10-01). A shell_execute that exits 1 with
# empty stdout AND stderr was read for two days as a "transient" and retried; it
# was never transient — on 09-30 it was curl.exe against the dead loopback port
# (curl exit 7 surfacing as an empty exit 1), and on 10-01 it was the launch
# gate's UIA probe, every single time. So every script now runs inside a wrapper
# that PRINTS its own failure to stdout: a trap for terminating errors, and on a
# non-zero native exit the exit code and every error PowerShell recorded. Empty
# stderr cannot hide a failure again. ($ErrorActionPreference is deliberately left
# at its default: making it Stop would turn the non-terminating errors the
# measured scripts already tolerate into new failures.)
def _wrap(script):
    return (
        "$Error.Clear(); "
        "trap { [Console]::Out.WriteLine('PS-ERROR: ' + ($_ | Out-String).Trim()); exit 1 }; "
        + script +
        "\n$__code = $LASTEXITCODE; "
        "if ($__code) { [Console]::Out.WriteLine('PS-NATIVE-EXIT: ' + $__code); "
        "foreach ($__e in $Error) { [Console]::Out.WriteLine('PS-ERROR: ' + ($__e | Out-String).Trim()) }; "
        "exit $__code }"
    )


def ps(script, reason, timeout=120, _attempt=0):
    """Run PowerShell on Drew's desktop and return stdout.

    Every command this file sends: `Get-CimInstance Win32_Process` (a census),
    `Get-Item`/`Get-ChildItem` over %APPDATA%\\SOLIDWORKS and %TEMP%\\swx*, a
    P/Invoke window census (EnumWindows + IsHungAppWindow + GetMenuState — all
    READ functions), and `curl.exe` to the bridge's loopback port. There is no
    process termination and no window message anywhere in it.
    """
    enc = base64.b64encode(_wrap(script).encode("utf-16le")).decode()
    r = subprocess.run(
        [
            "adom-bridge", "--target", "adom_drew2", "--ai-thread", THREAD, "shell_execute",
            json.dumps({
                "command": "powershell -NoProfile -EncodedCommand " + enc,
                "timeoutSeconds": timeout + 30,
                "reason": reason,
            }),
        ],
        capture_output=True, text=True,
    )
    try:
        payload = json.loads(r.stdout)
    except Exception:
        raise Unreadable("adom-bridge did not return JSON: %s%s"
                         % (r.stdout[:200], (" / " + r.stderr[:200]) if r.stderr else ""))
    if payload.get("success") is False:
        # A FAST, EMPTY exit 1 (under 5 s, nothing on stdout or stderr). It was
        # called "transient" and it was not: on 2026-09-30 it was curl.exe against
        # the dead loopback port (exit 7, surfacing as an empty exit 1), and on
        # 2026-10-01 it was the launch gate's UIA probe, deterministically — four
        # attempts in a row. Since `_wrap`, a script that fails prints WHY, so a
        # truly empty failure means the script never ran at all; the bounded
        # retry (three times, 2 s apart, each printed) stays for that case only.
        # Any other failure, or a fourth, is Unreadable — with the script's own
        # error text.
        fast_empty = (payload.get("exitCode") == 1 and not (payload.get("stdout") or "")
                      and not (payload.get("stderr") or "") and (payload.get("durationMs") or 99999) < 5000)
        if fast_empty and _attempt < PS_FAST_EMPTY_RETRIES:
            print("  (shell_execute failed fast with no output; retry %d of %d in %ds)"
                  % (_attempt + 1, PS_FAST_EMPTY_RETRIES, PS_RETRY_BACKOFF_SECS))
            time.sleep(PS_RETRY_BACKOFF_SECS)
            return ps(script, reason, timeout, _attempt=_attempt + 1)
        said = "\n".join(l for l in (payload.get("stdout") or "").splitlines()
                         if l.startswith("PS-ERROR: ") or l.startswith("PS-NATIVE-EXIT: "))
        raise Unreadable("shell_execute failed (exit %s): %s%s"
                         % (payload.get("exitCode"), said or "no error text at all — the script never ran",
                            "" if said else " / " + json.dumps(payload)[:200]))
    out = payload.get("stdout")
    if out is None:
        raise Unreadable("shell_execute returned no stdout: %s" % json.dumps(payload)[:300])
    return out.strip()


def verb(name, args=None, timeout=120):
    """Call a bridge verb THROUGH the desktop bridge's verb routing (adom-bridge
    routes swx_* to the bridge instance it hosts). Raises `Unreadable` rather
    than inventing an answer.

    MEASURED 2026-09-30: adom-bridge 2.1.163 hosts the released bridge itself
    (bridges-cache\\solidworks, its own port) and terminates a side-loaded copy
    on 24695 within 30 s, so the loopback route this helper used until 0.8.9 is
    dead. Set SWX_BRIDGE_URL to a loopback /command URL to force the old route.
    """
    import os
    routed = not os.environ.get("SWX_BRIDGE_URL")
    if not routed:
        body = json.dumps({"command": name, "args": args or {}})
        script = (
            "$b=@'\n" + body + "\n'@; $p=\"$env:TEMP\\swx_gate.json\"; "
            "[IO.File]::WriteAllText($p,$b); "
            f"curl.exe -s --max-time {timeout} -H \"Content-Type: application/json\" "
            f"--data-binary \"@$p\" {os.environ['SWX_BRIDGE_URL']}"
        )
        out = ps(script, f"SOLIDWORKS gate: calling {name}", timeout)
    else:
        # A VERB THAT DOES NOT ANSWER IS 'could not ask', not a crash. Without
        # this, subprocess.TimeoutExpired escaped as a traceback — a gate dying
        # on a slow verb before it could print a verdict or clean up.
        try:
            r = subprocess.run(
                ["adom-bridge", "--target", "adom_drew2", "--ai-thread", THREAD, name,
                 json.dumps(args or {})],
                capture_output=True, text=True, timeout=timeout + 60,
            )
        except subprocess.TimeoutExpired:
            raise Unreadable("%s did not answer through adom-bridge within %ds" % (name, timeout + 60))
        out = r.stdout
        i = out.find("{")
        if i < 0:
            raise Unreadable("%s did not answer with JSON: %r%s" % (name, out[:200], (" / " + r.stderr[:200]) if r.stderr else ""))
        out = out[i:]
    try:
        d = json.loads(out)
    except Exception:
        raise Unreadable("%s did not answer with JSON: %r" % (name, out[:300]))
    # adom-bridge wraps a ROUTED reply as {"status":..,"result":{...}} (and merges
    # top-level fields and adds an `output` string); the bridge's own reply is the
    # `result`. Only the routed reply is unwrapped: on the loopback route the JSON
    # IS the bridge's reply, and a verb field that happens to be called `result`
    # is not a wrapper.
    if routed and isinstance(d, dict) and isinstance(d.get("result"), dict):
        return d["result"]
    return d


# ── the machine, measured ───────────────────────────────────────────────────

CENSUS = r"""
$names = @('sldworks.exe','sldProcMon.exe','sldsurfacing.exe','sldimport.exe','swCefSubProc.exe',
           'ViewServerV6.exe','EdmServerV6.exe','ENOPLMCSAClient.exe','SWXDesktopHelper.exe',
           'sldBgDwld.exe','SWXDesktopLauncher.exe','CATSTART.exe','WerFault.exe')
$rows = Get-CimInstance Win32_Process | Where-Object {
    $names -contains $_.Name -or $_.Name -like '3DEXPERIENCELauncher*'
  } | ForEach-Object {
    [pscustomobject]@{ pid=$_.ProcessId; name=$_.Name; ppid=$_.ParentProcessId;
                       created=$_.CreationDate.ToUniversalTime().ToString('o') }
  }
@{ procs=@($rows) } | ConvertTo-Json -Depth 4 -Compress
"""

# EnumWindows + IsHungAppWindow + GetMenuState. Every one of these is a READ:
# there is no SendMessage, no PostMessage, no EndTask and no close in this block.
WINDOWS = r"""
param($TargetPid)
Add-Type -Namespace GateW -Name U -MemberDefinition @'
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
public delegate bool EnumProc(IntPtr h, IntPtr p);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h, System.Text.StringBuilder s, int n);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, System.Text.StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsHungAppWindow(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetSystemMenu(IntPtr h, bool revert);
[DllImport("user32.dll")] public static extern uint GetMenuState(IntPtr menu, uint item, uint flags);
'@
$found = New-Object System.Collections.ArrayList
$cb = [GateW.U+EnumProc]{
  param($h,$p)
  $wpid = 0
  [void][GateW.U]::GetWindowThreadProcessId($h, [ref]$wpid)
  if ($wpid -eq $TargetPid) {
    $c = New-Object System.Text.StringBuilder 256
    [void][GateW.U]::GetClassName($h, $c, 256)
    $t = New-Object System.Text.StringBuilder 512
    [void][GateW.U]::GetWindowText($h, $t, 512)
    $menu = [GateW.U]::GetSystemMenu($h, $false)
    $sc = if ($menu -ne [IntPtr]::Zero) { [GateW.U]::GetMenuState($menu, 0xF060, 0) } else { 0xFFFFFFFF }
    [void]$found.Add([pscustomobject]@{
      hwnd=[int64]$h; class=$c.ToString(); title=$t.ToString();
      visible=[GateW.U]::IsWindowVisible($h); enabled=[GateW.U]::IsWindowEnabled($h);
      hung=[GateW.U]::IsHungAppWindow($h); scClose=[int64]$sc })
  }
  return $true
}
[void][GateW.U]::EnumWindows($cb, [IntPtr]::Zero)
@{ windows=@($found) } | ConvertTo-Json -Depth 4 -Compress
"""

FILES = r"""
$out = @{}
$j = Join-Path $env:APPDATA 'SOLIDWORKS\SOLIDWORKS 2026\swxJRNL.swj'
if (Test-Path $j) {
  $f = Get-Item $j
  $out.journal = @{ path=$f.FullName; bytes=$f.Length; modified=$f.LastWriteTimeUtc.ToString('o') }
} else { $out.journal = $null }
$out.tempDirs = @(Get-ChildItem $env:TEMP -Directory -Filter 'swx*' -ErrorAction SilentlyContinue |
  ForEach-Object { @{ name=$_.Name; created=$_.CreationTimeUtc.ToString('o') } })
$p = Join-Path $env:LOCALAPPDATA 'DassaultSystemes\3dexperience-log.json'
$out.platformLog = if (Test-Path $p) { (Get-Item $p).LastWriteTimeUtc.ToString('o') } else { $null }
$out | ConvertTo-Json -Depth 5 -Compress
"""


def census(why):
    out = ps(CENSUS, "SOLIDWORKS quit gate: " + why, 90)
    try:
        d = json.loads(out)
    except Exception:
        raise Unreadable("the process census did not answer with JSON: %r" % out[:300])
    rows = d.get("procs")
    rows = rows if isinstance(rows, list) else ([] if rows is None else [rows])
    # A census that answered with nothing at all is INDISTINGUISHABLE from one
    # that failed silently, and here it would read as "SOLIDWORKS quit". The
    # baseline proves the probe works; after that, an empty list is a fact.
    return [{"pid": int(r["pid"]), "name": str(r["name"]),
             "ppid": r.get("ppid"), "created": str(r.get("created") or "")} for r in rows]


def windows_of(pid, why):
    script = "$TargetPid = %d\n" % int(pid) + WINDOWS.replace("param($TargetPid)\n", "")
    out = ps(script, "SOLIDWORKS quit gate: " + why, 90)
    try:
        d = json.loads(out)
    except Exception:
        raise Unreadable("the window census did not answer with JSON: %r" % out[:300])
    rows = d.get("windows")
    return rows if isinstance(rows, list) else ([] if rows is None else [rows])


def files(why):
    out = ps(FILES, "SOLIDWORKS quit gate: " + why, 90)
    try:
        return json.loads(out)
    except Exception:
        raise Unreadable("the journal/temp probe did not answer with JSON: %r" % out[:300])


def named(rows, *names):
    return [r for r in rows if r["name"] in names]


def identity_of(rows):
    """(pid, creation time) for sldworks.exe, or None. TWO of them is a refusal:
    the gate would not know which one it had ended."""
    sw = named(rows, "sldworks.exe", "SLDWORKS.exe")
    if len(sw) > 1:
        raise Refused("%d sldworks.exe processes are running (%s) — this gate ends ONE session "
                      "and cannot tell them apart" % (len(sw), [p["pid"] for p in sw]))
    return (sw[0]["pid"], sw[0]["created"]) if sw else None


def presence_verdict(reply):
    """THE USER-PRESENCE GUARD (0.9.9), as a gate reads it. Returns
    ("absent" | "present", message). A gate NEVER passes userPresentOverride:
    the session belongs to the person using it, and a gate is not that person.
    An unreadable reply is "present" — fail closed."""
    if not isinstance(reply, dict) or reply.get("success") is False and reply.get("errorCode") != "user_is_active":
        return ("present", "the bridge could not say whether the user is working in SOLIDWORKS (%s) — "
                           "the gate does not run on a guess" % (reply.get("error") if isinstance(reply, dict) else reply))
    if reply.get("errorCode") == "user_is_active":
        ev = reply.get("userPresence") or {}
        return ("present", "the bridge refused: the user is working in this SOLIDWORKS (%s)" % ev.get("basis"))
    if reply.get("present") is False:
        return ("absent", "no sign of the user: %s" % ((reply.get("evidence") or {}).get("basis")))
    ev = reply.get("evidence") or {}
    return ("present", "THE USER IS WORKING IN THIS SOLIDWORKS (%s; foreground=%s, %ss since last input, "
                       "%s document(s) the bridge did not open). This gate does not run on a session "
                       "somebody is using, and never overrides that — wait until they are done."
            % (ev.get("basis"), (ev.get("foregroundWindow") or {}).get("title"),
               ev.get("secondsSinceLastInput"), (ev.get("foreignDocuments") or {}).get("count")))


def dialog_verdict(judged):
    """The BRIDGE's verdict decides, never a raw window list. Returns
    ("clear" | "blocking" | "unknown", message). Only a BLOCKING dialog is handed
    to the user (a screenshot, their decision); a notification is listed and the
    gate goes on; an unreadable census refuses without blaming the user's screen."""
    if not isinstance(judged, dict) or judged.get("readable") is not True:
        return ("unknown", "the bridge could not judge the dialogs on screen (%s) — whether one "
                           "blocks is UNKNOWN, so the gate does not quit on a guess. Nothing was "
                           "attempted." % ((judged or {}).get("why") if isinstance(judged, dict) else judged))
    b = judged.get("blocking")
    if b:
        return ("blocking", "a BLOCKING dialog is on screen (title=%r kind=%s: %s). This bridge never "
                            "answers one, and a gate must not either. A blocking dialog is the one "
                            "thing that is genuinely the user's to answer — show them a screenshot "
                            "(swx_state) and let them decide. Closing documents is NOT theirs to do: "
                            "the sweep does that." % (b.get("title"), b.get("kind"), b.get("why")))
    return ("clear", "no blocking dialog (%d notification(s) listed, never answered)"
            % len(judged.get("notifications") or []))


def is_dialog(w):
    return str(w.get("class") or "") == "#32770"


FAILURES = []
NOTES = []


def check(ok, claim, detail=""):
    print(("  PASS  " if ok else "  FAIL  ") + claim + (f"   [{detail}]" if detail else ""))
    if not ok:
        FAILURES.append(claim + (f" [{detail}]" if detail else ""))
    return ok


def show(title, obj):
    print("\n" + title)
    print("  " + json.dumps(obj, indent=2, default=str).replace("\n", "\n  "))


# ── step 0: baseline, read-only ─────────────────────────────────────────────

def baseline():
    print("=" * 72)
    print("STEP 0 — BASELINE (read-only; nothing is started, stopped or answered)")
    rows = census("baseline process census, with creation times")
    for r in sorted(rows, key=lambda r: r["name"]):
        print("  %-34s pid=%-8s ppid=%-8s created=%s" %
              (r["name"], r["pid"], r["ppid"], r["created"]))
    ident = identity_of(rows)
    if ident is None:
        raise Refused("no sldworks.exe is running — there is nothing to quit. Start it with "
                      "swx_start (this gate must run on a BRIDGE-LAUNCHED session) and re-run.")
    pid, created = ident
    print("\n  SOLIDWORKS identity: pid=%s created=%s" % (pid, created))

    wins = windows_of(pid, "baseline window census for the SOLIDWORKS pid")
    for w in wins:
        print("  hwnd=%-10s %-42s visible=%-5s enabled=%-5s hung=%-5s scClose=0x%X %r" %
              (w.get("hwnd"), str(w.get("class"))[:42], w.get("visible"), w.get("enabled"),
               w.get("hung"), int(w.get("scClose") or 0), str(w.get("title"))[:40]))
    dialogs = [w for w in wins if is_dialog(w) and w.get("visible")]
    # MF_GRAYED (0x1) | MF_DISABLED (0x2) on SC_CLOSE means the window-manager
    # rungs are not even available on this app right now.
    main = next((w for w in wins if str(w.get("class") or "").startswith("Afx:")), None)
    if main:
        state = int(main.get("scClose") or 0)
        print("  SC_CLOSE on the main frame: 0x%X — Close is %s" %
              (state, "DISABLED" if state & 0x3 else "ENABLED"))

    # THE BRIDGE'S VERDICT on those windows (0.9.6, G2): blocking vs notification,
    # judged by measured modality and by the window's words. A visible #32770 is
    # NOT a dialog somebody must answer — the 3DEXPERIENCE "Connected" notice
    # (title '') is one, and refusing on it made this gate refuse its own baseline.
    judged = verb("swx_window_info", {}, 60).get("dialogCensus")
    show("the bridge's dialog census (blocking vs notification)", judged)
    st = verb("swx_status", {}, 60)
    app = verb("swx_get_app_state", {}, 60)
    show("swx_status.solidworks (the bridge's own reading, recorded, not trusted)",
         st.get("solidworks"))
    show("swx_status.launch", st.get("launch"))
    show("swx_get_app_state", app)
    show("journal / %TEMP%\\swx* / platform log", files("journal, temp dirs and platform log"))
    led = verb("swx_action_ledger", {"limit": 40}, 60)
    print("\n  ledger tail (launch + quit events)")
    for a in led.get("actions", []):
        act = str(a.get("action") or "")
        if "solidworks" in act:
            print("    %s %s %s" % (a.get("at"), act, json.dumps(a.get("detail"))[:140]))

    # WAS THIS SESSION STARTED BY THE BRIDGE? §5.7 requires it, so a bad relaunch
    # can never be blamed on how the session began — and the first version of this
    # gate ASSERTED that in its docstring and checked nothing.
    require_bridge_launched(st, created)
    return {"pid": pid, "created": created, "procs": rows, "windows": wins,
            "dialogs": dialogs, "judged": judged, "status": st, "app": app}


def to_epoch(iso):
    """An ISO-8601 UTC stamp as epoch seconds, or None. `None` is UNKNOWN and
    every caller here treats it as a refusal, never as a pass."""
    if not iso:
        return None
    try:
        import datetime
        t = str(iso).replace("Z", "+00:00")
        return int(datetime.datetime.fromisoformat(t).timestamp())
    except Exception:
        return None


def require_bridge_launched(status, sldworks_created_iso):
    """REFUSE unless the live SOLIDWORKS was started by THIS bridge, proven.

    Three facts, all from `swx_status.launch`, and any one of them unreadable is a
    refusal:

      * `attributed` — the launch had a root the INVOKE ITSELF handed back (a
        `hProcess` from ShellExecuteExW, or a pid from `Start-Process -PassThru`).
        Nothing found by searching the machine afterwards is ever adopted, so this
        is the bridge's own "no handle, no kill" evidence standard.
      * `runBeginSeen` — the launcher log attributed to that root logged `Run
        Begin`, so the chain we started is the chain that ran.
      * the live `sldworks.exe` was created AT OR AFTER that attempt's `invokedAt`.
        A process that already existed cannot have been started by our invoke, and
        this is what ties the ATTEMPT to the process the ladder is about to end.

    "The ledger mentions a launch" is not enough: it can name an attempt that
    failed while the user started SOLIDWORKS from the Start Menu.
    """
    launch = status.get("launch")
    if not isinstance(launch, dict):
        raise Refused("swx_status returned no launch block, so 'bridge-launched' cannot be "
                      "proven — and §5.7 runs this only on a session the bridge started")
    attributed = launch.get("attributed")
    run_begin = launch.get("runBeginSeen")
    invoked = launch.get("invokedAt")
    created = to_epoch(sldworks_created_iso)
    print("\n  bridge-launched proof: attributed=%r runBeginSeen=%r invokedAt=%r "
          "sldworksCreated=%r" % (attributed, run_begin, invoked, created))
    if attributed is not True:
        raise Refused("this bridge cannot prove it launched the running SOLIDWORKS "
                      "(launch.attributed=%r). Its attribution law is 'the invoke itself, or "
                      "nothing' — so this session may be the user's own, and the gate will not "
                      "end a session it cannot account for. Quit it yourself, then swx_start a "
                      "fresh one and re-run." % attributed)
    if run_begin is not True:
        raise Refused("the attributed launcher chain never logged `Run Begin` "
                      "(launch.runBeginSeen=%r), so the chain this bridge started is not the "
                      "chain that produced the running SOLIDWORKS." % run_begin)
    if invoked is None or created is None:
        raise Refused("cannot compare the launch instant (%r) with the SOLIDWORKS creation time "
                      "(%r) — one of them is unreadable, and an unreadable precondition is a "
                      "refusal here exactly as it is in the bridge." % (invoked, sldworks_created_iso))
    if created + 5 < int(invoked):
        raise Refused("the running SOLIDWORKS was created %ds BEFORE this bridge's launch attempt, "
                      "so it is not the process that attempt started — it is almost certainly the "
                      "user's own session." % (int(invoked) - created))
    print("  PASS  the running SOLIDWORKS was launched by THIS bridge (attributed + Run Begin + "
          "created after the invoke)")


def prove_zero_documents(base):
    """The step that makes the rest safe. A doubt here ABORTS."""
    print("\n" + "=" * 72)
    print("STEP 1 — ZERO DOCUMENTS (a doubt here refuses the whole run)")
    app = base["app"]
    count = app.get("documentCount")
    unsaved = app.get("unsavedDocuments")
    truncated = app.get("documentsTruncated")
    docs = verb("swx_docs", {}, 90)
    listed = docs.get("documents")
    print("  app_state: documentCount=%r unsavedDocuments=%r documentsTruncated=%r" %
          (count, unsaved, truncated))
    print("  swx_docs:  count=%r truncated=%r listed=%s" %
          (docs.get("count"), docs.get("truncated"),
           len(listed) if isinstance(listed, list) else listed))
    if truncated or docs.get("truncated"):
        raise Refused("the document enumeration is TRUNCATED, so 'no documents are open' cannot "
                      "be proven. That is UNKNOWN, and UNKNOWN is not permission.")
    if count is None or unsaved is None:
        raise Refused("the document count is UNKNOWN (documentCount=%r unsavedDocuments=%r) — "
                      "refusing to quit on a guess." % (count, unsaved))
    if count != 0 or unsaved != 0:
        # THE BRIDGE CLOSES. Drew, 2026-09-11: "I despise every time you ask for
        # help closing. You MUST find/build a way to safely close the
        # docs/program." This gate used to end here by telling him to do it
        # himself, which is the sentence that directive exists to delete — and it
        # was still in the tool a week after the sweep that makes it unnecessary
        # shipped and ran live.
        #
        # swx_close_all_safely writes every open document to a recovery folder,
        # VERIFIES each one (container marker + per-kind size), and only then
        # closes it; anything it could not preserve is left open and named. So the
        # gate sweeps, and refuses only if the SWEEP refuses — saying which of its
        # reasons applied, because they are not interchangeable.
        print("  %r document(s) open, %r unsaved — closing them SAFELY first "
              "(swx_close_all_safely preserves every one to recovery, verified, before closing)"
              % (count, unsaved))
        swept = verb("swx_close_all_safely", {
            "reason": "quit gate: emptying the session safely so the quit ladder can be measured "
                      "on a session with zero documents open",
        })
        if swept.get("success") is not True or swept.get("done") is not True:
            code = swept.get("errorCode") or "?"
            why = {
                "stopped_by_dialog":
                    "a dialog is on screen and this bridge never answers one: %s"
                    % json.dumps(swept.get("stoppedByDialog"))[:300],
                "dialog_census_unknown":
                    "the window census could not be read, so whether a dialog is up is UNKNOWN — "
                    "the documents were preserved and NONE were closed",
                "insufficient_headroom":
                    "the recovery volume cannot hold the session: %s"
                    % json.dumps(swept.get("stoppedByHeadroom"))[:200],
                "identity_mismatch":
                    "the running SOLIDWORKS is not the session this bridge is attributed to",
            }.get(code, "the sweep stopped: %s" % str(swept.get("error"))[:300])
            remaining = swept.get("remaining") or []
            raise Refused(
                "the session could not be emptied safely, so the quit is not attempted. %s%s "
                "NOTHING was lost: every document the sweep could preserve is in %s, and anything "
                "it could not preserve is still open and named in `remaining`."
                % (why,
                   (" Documents it could not preserve: %s." % json.dumps(remaining)[:300])
                   if remaining else "",
                   swept.get("recoveryDir")))
        print("  swept: %d preserved, %d closed, recoveryDir=%s"
              % (len(swept.get("recovered") or []), swept.get("closed") or 0,
                 swept.get("recoveryDir")))
        # RE-MEASURE. Everything that was measured the first time — all four
        # conditions, not just the count.
        #
        # The preamble above disqualifies a session on FOUR grounds: a truncated
        # enumeration (UNKNOWN is not permission), an unknown count or unsaved
        # figure, a non-zero count, and a count that disagrees with the listing.
        # The first version of this re-measure re-read `unsavedDocuments` into a
        # variable it never tested, never re-read `documentsTruncated`, and never
        # re-fetched the listing — so the cross-check below compared a POST-sweep
        # count against a PRE-sweep list, and the sweep is the very thing that
        # invalidates that list.
        state = verb("swx_get_app_state", {})
        docs = verb("swx_docs", {})
        count = state.get("documentCount")
        unsaved = state.get("unsavedDocuments")
        truncated = state.get("documentsTruncated")
        listed = docs.get("docs") if isinstance(docs.get("docs"), list) else None
        if truncated or docs.get("truncated"):
            raise Refused(
                "after the safe close the document enumeration is TRUNCATED, so 'the session is "
                "empty' cannot be proven. UNKNOWN is not permission, and this gate ENDS the "
                "session.")
        if count is None or unsaved is None:
            raise Refused(
                "after the safe close the document count is UNKNOWN (documentCount=%r "
                "unsavedDocuments=%r) — refusing to quit on a guess." % (count, unsaved))
        if count != 0 or unsaved != 0:
            raise Refused(
                "the safe close reported done but %r document(s) are open and %r unsaved — "
                "refusing to quit on a session whose state this gate cannot confirm is empty. "
                "Nothing was lost: whatever the sweep preserved is in its recovery folder."
                % (count, unsaved))
        if listed is None:
            raise Refused(
                "after the safe close swx_docs returned no usable list, so the count cannot be "
                "cross-checked against the enumeration. That is UNKNOWN, not empty.")
        if len(listed) != 0:
            raise Refused(
                "after the safe close documentCount is 0 but the enumeration still lists %d "
                "document(s) — the two disagree, and a quit on a disagreement is a quit on a "
                "guess: %s" % (len(listed), json.dumps([d.get("title") for d in listed])[:200]))
        print("  re-measured after the sweep: count=0, unsaved=0, enumeration empty, "
              "not truncated")
    # A DRAIN THAT MATCHED NOTHING IS NOT A DRAIN THAT FOUND NOTHING, and the
    # operator's next step after a refusal here is the drain rather than the quit
    # — so say whether the open documents LOOK like leaked import temporaries.
    # Read-only: this reads the paths `swx_docs` already returned and applies the
    # tail pattern here. It does NOT call swx_close_import_temps — that verb
    # CLOSES documents, and the only action this gate is allowed is swx_stop.
    tails = [d for d in (listed if isinstance(listed, list) else [])
             if TEMP_TAIL.search(str(d.get("path") or ""))]
    if tails:
        print("  NOTE: %d of the open documents carry the launcher's swx<pid>\\IC~~\\<file> tail, "
              "so they are almost certainly leaked import temporaries: %s"
              % (len(tails), str(tails[0].get("path"))))
        print("        Run `swx_close_import_temps` (a separate step, with a reason) and check its "
              "`tempRoot` / `importTempsElsewhere` — a drain that closes nothing while those "
              "documents are open means the bridge's %TEMP% is not the one SOLIDWORKS used.")
    if isinstance(listed, list) and len(listed) != 0:
        raise Refused("the count says 0 but the enumeration lists %d — they DISAGREE, so neither "
                      "is evidence." % len(listed))
    who, why_present = presence_verdict(verb("swx_user_presence", {}, 60))
    print("  user presence: %s — %s" % (who, why_present))
    if who != "absent":
        raise Refused(why_present)
    claim, why = dialog_verdict(base.get("judged"))
    if claim == "blocking":
        raise Refused(why)
    if claim == "unknown":
        raise Refused(why)
    for n in (base.get("judged") or {}).get("notifications") or []:
        print("  NOTIFICATION on screen (not blocking, never answered): title=%r kind=%s why=%s"
              % (n.get("title"), n.get("kind"), n.get("why")))
    if base["status"].get("solidworks", {}).get("busy") is True:
        raise Refused("SOLIDWORKS reports BUSY — a rung would land on a modal operation.")
    check(True, "0 documents, 0 unsaved, count agrees with the enumeration, no dialog")
    return True


# ── step 2: the one action in this file ─────────────────────────────────────

def run_ladder(base, max_rung, allow_rm, reason):
    print("\n" + "=" * 72)
    print("STEP 2 — THE LADDER (%s)" % ("maxRung=" + max_rung if max_rung else "the bridge default"))
    args = {
        # Drew's STANDING AUTHORIZATION (2026-09-11): `userAuthorized:true` with a
        # reason is the grant for ending the session (writes.rs, swx_stop). The
        # write-gate pair (allowUserWrites/confirm + SWX_ALLOW_USER_WRITES on the
        # bridge process) is NOT what authorizes a quit, and the bridge that ab
        # hosts never carries that env var - measured 2026-09-30: the gate was
        # refused `user_writes_disabled` while attribution was intact.
        "userAuthorized": True,
        "confirm": True,
        "reason": reason,
        # `force` waives the UNSAVED question only and there is nothing unsaved,
        # so it is deliberately NOT passed: the gate should exercise the same
        # posture a caller gets by default.
    }
    if max_rung:
        args["maxRung"] = max_rung
    if allow_rm:
        args["allowRestartManager"] = True
    t0 = time.time()
    r = verb("swx_stop", args, 240)
    if r.get("errorCode") == "user_is_active":
        raise Refused(presence_verdict(r)[1])
    show("swx_stop returned (recorded; every claim below comes from the machine)", r)

    # ── the §5.4 measurement set, from the machine, at +2/+5/+10/+30s ───────
    gone_at = None
    last = None
    for at in SETTLE:
        while time.time() - t0 < at:
            time.sleep(0.5)
        rows = census("measuring the machine at +%ds after the ladder" % at)
        last = rows
        sw = named(rows, "sldworks.exe", "SLDWORKS.exe")
        # THE SUCCESS TEST: this pid AND this creation time are absent. A pid
        # alone is not enough — Windows reuses them.
        same = [p for p in sw if p["pid"] == base["pid"] and p["created"] == base["created"]]
        kids = sorted({p["name"] for p in named(rows, *SESSION_CHILDREN)})
        print("  +%-3ds sldworks=%-22s children=%s" %
              (at, ("gone" if not same else "pid %s alive" % base["pid"]), kids or "none"))
        if not same and gone_at is None:
            gone_at = time.time() - t0
            break

    print("\nSTEP 3 — AFTERMATH")
    rows = last if last is not None else census("aftermath census")
    check(gone_at is not None,
          "the SOLIDWORKS process (pid %s + its creation time) is GONE from the census" % base["pid"],
          ("after %.1fs" % gone_at) if gone_at else "still present at +%ds" % SETTLE[-1])
    if gone_at is not None:
        kids = named(rows, *SESSION_CHILDREN)
        check(not kids, "the per-session servers were REAPED with it",
              ", ".join("%s(%s)" % (p["name"], p["pid"]) for p in kids) or "none left")
    survivors = sorted({p["name"] for p in named(rows, *EXPECTED_SURVIVORS)})
    print("  expected survivors still alive (this is CONSENT, not a leak): %s" % (survivors or "none"))
    print("  undocumented, recorded only: %s" %
          (sorted({p["name"] for p in named(rows, *UNJUDGED)}) or "none"))

    # Whatever happened, did it leave something on the user's screen?
    wins = windows_of(base["pid"], "checking whether anything was left on screen")
    live = [w for w in wins if w.get("visible")]
    check(not live, "nothing of that session is left on screen",
          json.dumps([{"class": w.get("class"), "title": w.get("title")} for w in live])[:200])
    wer = named(rows, "WerFault.exe")
    check(not wer, "no crash reporter appeared", str([p["pid"] for p in wer]))
    show("journal / %TEMP%\\swx* / platform log, after", files("journal and temp dirs after the quit"))

    # The bridge's own answer, checked against the machine rather than believed.
    stopped = r.get("stopped")
    check((stopped is True) == (gone_at is not None),
          "the bridge's `stopped` agrees with the machine",
          "stopped=%r, machine says gone=%r" % (stopped, gone_at is not None))
    if r.get("stillRunning") is not None:
        check((r.get("stillRunning") is False) == (gone_at is not None),
              "...and so does `stillRunning`", "stillRunning=%r" % r.get("stillRunning"))
    check(isinstance(r.get("rungsTried"), list) and len(r["rungsTried"]) >= 1,
          "the report says which rungs were tried", json.dumps(r.get("rungsTried"))[:300])
    for t in r.get("rungsTried") or []:
        print("  rung %s: result=%s processGone=%s newWindows=%s %s" %
              (t.get("rung"), t.get("result"), t.get("processGone"), t.get("newWindows"),
               str(t.get("detail") or "")[:120]))
    # THE FULL DIALOG EVIDENCE, whenever a window stopped the ladder (0.9.4).
    # 2026-10-01 a title-less window stopped it with `kind: unknown` and nothing
    # else to judge it by; it turned out to be a NOTIFICATION. Print every field
    # the census measured — owner, ownerEnabled, styles, rect, child controls —
    # so the next one can be judged from this output alone.
    pre = r.get("preconditions") or {}
    blocked = r.get("blockingDialog") or pre.get("dialog")
    if blocked:
        show("BLOCKING WINDOW — full evidence (blocking=%s, why=%s)"
             % (blocked.get("blocking"), blocked.get("why")), blocked)
    for n in pre.get("notifications") or []:
        show("NOTIFICATION (not blocking, never answered) — evidence", n)
    if r.get("blockingDialog"):
        NOTES.append("a dialog stopped the ladder: %s — it is STILL ON SCREEN and this gate did "
                     "not answer it — show the user a screenshot (swx_state) and let them "
                     "answer. (Closing documents is not something to ask for: "
                     "swx_close_all_safely does it.)" % json.dumps(r["blockingDialog"]))
    st = verb("swx_status", {}, 60)
    claim_status_not_running(st)
    return gone_at is not None


def claim_status_not_running(st):
    """THE STATUS CLAIM, and only true when it is true.

    It used to read `running is not True or gone_at is None` — so after a
    DECLINED quit (gone_at None) it printed PASS while swx_status said
    `running: true`, twice (2026-09-30 and 2026-10-01). A claim that passes on
    the outcome it exists to catch is not a claim. Now `running` must be
    exactly false; true, null or absent FAILS. `--self-test` pins it with
    canned replies.
    """
    running = st.get("com", {}).get("running")
    return check(running is False,
                 "swx_status no longer reports a running SOLIDWORKS",
                 "com.running=%s %s" % (json.dumps(running), json.dumps(st.get("com"))[:160]))


def self_test():
    """The gate's own claims, against canned replies — no machine, no bridge.

    A declined quit leaves `com.running: true`; the status claim must FAIL on
    it. A quit leaves `running: false`; it must PASS. An unreadable status
    (no `running`) must FAIL, because unknown is not gone.
    """
    cases = [
        ("declined quit: swx_status still says running:true", {"com": {"running": True, "attached": True}}, False),
        ("unreadable status: no running field", {"com": {"busy": True}}, False),
        ("real quit: running:false", {"com": {"running": False}}, True),
    ]
    bad = []
    for label, reply, want in cases:
        before = len(FAILURES)
        got = claim_status_not_running(reply)
        if got != want or (len(FAILURES) > before) == want:
            bad.append(label)
    del FAILURES[:]
    # THE DIALOG VERDICT (0.9.6, G2): the measured Connected notice — title '',
    # judged a notification by the bridge — must NOT refuse the gate; a blocking
    # dialog must; an unreadable census must refuse WITHOUT telling the reader to
    # hand a dialog to the user.
    notice = {"title": "", "kind": "notification", "blocking": False,
              "why": "a tool window whose owner frame is ENABLED"}
    verdicts = [
        ("the Connected notification alone", {"readable": True, "blocking": None, "notifications": [notice]}, "clear"),
        ("a blocking save prompt", {"readable": True, "blocking": {"title": "SOLIDWORKS", "kind": "save_changes",
                                    "why": "by its text"}, "notifications": [notice]}, "blocking"),
        ("an unreadable census", {"readable": False, "why": "no process census"}, "unknown"),
        ("no census at all", None, "unknown"),
    ]
    presences = [
        ("the user at the keyboard", {"success": True, "present": True,
                                       "evidence": {"basis": "SOLIDWORKS is the foreground window"}}, "present"),
        ("nobody there", {"success": True, "present": False, "evidence": {"basis": "no sign"}}, "absent"),
        ("the bridge refused user_is_active", {"success": False, "errorCode": "user_is_active",
                                                "userPresence": {"basis": "documents appeared"}}, "present"),
        ("an unreadable reply", {"success": False, "error": "bridge down"}, "present"),
        ("no reply at all", None, "present"),
    ]
    for label, reply, want in presences:
        if presence_verdict(reply)[0] != want:
            bad.append("presence verdict: " + label)
    for label, judged, want in verdicts:
        got, msg = dialog_verdict(judged)
        if got != want:
            bad.append("dialog verdict: " + label)
        if want == "unknown" and "screenshot" in msg:
            bad.append("dialog verdict: an unreadable census told the reader to show the user a screenshot")
        if want == "clear" and "screenshot" in msg:
            bad.append("dialog verdict: a notification was handed to the user")
    print("\n" + "=" * 72)
    if bad:
        print("SELF-TEST FAILED — the status claim gave the wrong verdict for: " + "; ".join(bad))
        return 1
    print("SELF-TEST PASSED — the status claim FAILS on a declined quit and an unreadable status, "
          "and PASSES only on running:false; the dialog verdict lists a notification and refuses "
          "only on a blocking dialog or an unreadable census.")
    return 0


# ── step 4: the question that decides whether quitting is offerable ─────────

def relaunch():
    print("\n" + "=" * 72)
    print("STEP 4 — THE RELAUNCH (a quit that costs the next launch is not graceful)")
    t0 = time.time()
    r = verb("swx_start", {"reason": "quit gate: measuring the launch after a graceful quit"}, 90)
    show("swx_start", {k: r.get(k) for k in
                       ("success", "stage", "stillLaunching", "launchedNow", "route", "errorCode")})
    sld_at, attach_at = None, None
    while time.time() - t0 < RELAUNCH_BUDGET:
        st = verb("swx_status", {}, 60)
        launch = st.get("launch") or {}
        if sld_at is None and (st.get("solidworks") or {}).get("processId"):
            sld_at = time.time() - t0
        if (st.get("com") or {}).get("running") or launch.get("stage") == "attached":
            attach_at = time.time() - t0
            break
        if launch.get("finished") and launch.get("outcome") not in (None, "attached"):
            print("  ... the machine gave up: %s — %s" % (launch.get("outcome"), launch.get("error")))
            break
        print("  ... stage=%s route=%s %ds" % (launch.get("stage"), launch.get("route"),
                                               int(time.time() - t0)))
        time.sleep(POLL)
    print("  sldworks.exe at %s, COM attach at %s" %
          ("%.1fs" % sld_at if sld_at else "never", "%.1fs" % attach_at if attach_at else "never"))
    check(attach_at is not None,
          "SOLIDWORKS came back after the quit (within %ds)" % RELAUNCH_BUDGET,
          "%.1fs" % attach_at if attach_at else "never — quitting is NOT operationally safe yet")
    if attach_at and attach_at > 200:
        NOTES.append("the relaunch took %.0fs; the 2026-09-08 healthy launch attached in 60-180s"
                     % attach_at)


def main():
    ap = argparse.ArgumentParser(description="prove the graceful quit ladder on the real machine")
    ap.add_argument("--baseline", action="store_true",
                    help="steps 0 and 1 only — read-only, ends nothing")
    ap.add_argument("--rung", metavar="a..f",
                    help="stop the ladder at this rung (the bridge's default is 'a')")
    ap.add_argument("--allow-restart-manager", action="store_true",
                    help="permit rung (f); its OS semantics are save-without-prompting")
    ap.add_argument("--no-relaunch", action="store_true", help="skip step 4")
    ap.add_argument("--reason", default="quit gate: measuring the graceful quit ladder "
                                        "on an empty, bridge-launched session (Drew approved)")
    ap.add_argument("--self-test", action="store_true",
                    help="check the gate's own claims against canned replies; touches nothing")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if a.rung == "f" and not a.allow_restart_manager:
        print("rung (f) needs --allow-restart-manager: the Restart Manager tells applications to "
              "save WITHOUT prompting, so it is never taken implicitly.")
        return 2

    base = baseline()
    prove_zero_documents(base)
    if a.baseline:
        print("\n" + "=" * 72)
        print("BASELINE ONLY — nothing was stopped. Re-run without --baseline to end the session.")
        return 1 if FAILURES else 0

    quit_ok = run_ladder(base, a.rung, a.allow_restart_manager, a.reason)
    if quit_ok and not a.no_relaunch:
        relaunch()
    elif not quit_ok:
        NOTES.append("SOLIDWORKS did not quit, so step 4 was skipped — the session is still up "
                     "and nothing was killed. That is the designed outcome of a decline.")

    print("\n" + "=" * 72)
    for n in NOTES:
        print("NOTE: " + n)
    if FAILURES:
        print("GATE FAILED — %d claim(s) did not hold:" % len(FAILURES))
        for f in FAILURES:
            print("  - " + f)
        return 1
    print("GATE PASSED — measured from the machine: the session ended, its children were reaped, "
          "the platform daemons were untouched, and nothing was killed.")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Refused as e:
        print("\n" + "=" * 72)
        print("GATE REFUSED TO RUN — nothing was attempted.")
        print("  %s" % e)
        sys.exit(2)
    except Unreadable as e:
        # NOT a failure of the claim — a failure to ASK. Exit non-zero so it can
        # never be mistaken for a pass. Here it matters twice over: an
        # unreadable census looks exactly like a successful quit.
        print("\n" + "=" * 72)
        print("GATE ABORTED — a probe could not be read, so nothing below it is proven.")
        print("  %s" % e)
        print("This is 'could not ask', not 'nothing found'. Fix the probe and re-run.")
        sys.exit(3)