From c5c27d6bf86f60d142564172295a45d3e5ec7f3c Mon Sep 17 00:00:00 2001 From: Sean Cheatham Date: Thu, 23 Jul 2026 20:29:47 -0400 Subject: [PATCH] Vendor initial set of official antithesis skills --- .../skills/antithesis-agent-browser/SKILL.md | 163 ++ .../assets/antithesis-agent-browser.js | 1492 +++++++++++++++++ .../assets/download-logs-from-url.sh | 120 ++ .../references/error-reports.md | 103 ++ .../references/properties-from-ui.md | 119 ++ .../references/run-discovery-ui.md | 42 + .../references/setup-auth.md | 44 + .claude/skills/antithesis-debug/SKILL.md | 303 ++++ .../assets/antithesis-debug.js | 896 ++++++++++ .../assets/download-mvd-log.sh | 158 ++ .../antithesis-debug/assets/process-logs.py | 1389 +++++++++++++++ .../antithesis-debug/references/actions.md | 153 ++ .../references/advanced-debugger.md | 458 +++++ .../references/common-inspections.md | 272 +++ .../references/download-log.md | 102 ++ .../antithesis-debug/references/notebook.md | 188 +++ .../references/setup-session.md | 177 ++ .../references/simplified-debugger.md | 419 +++++ .../skills/antithesis-documentation/SKILL.md | 91 + .claude/skills/antithesis-launch/SKILL.md | 86 + .claude/skills/antithesis-query-logs/SKILL.md | 228 +++ .../assets/antithesis-query-logs.js | 731 ++++++++ .../antithesis-query-logs/assets/build-url.py | 508 ++++++ .../antithesis-query-logs/references/map.md | 44 + .../references/query-builder.md | 210 +++ .../references/results.md | 58 + .../references/temporal-queries.md | 255 +++ .claude/skills/antithesis-research/SKILL.md | 187 +++ .../references/deployment-topology.md | 109 ++ .../antithesis-research/references/faults.md | 178 ++ .../references/property-catalog.md | 197 +++ .../references/property-discovery.md | 320 ++++ .../references/property-evaluation.md | 315 ++++ .../references/scratchbook-artifacts.md | 109 ++ .../references/scratchbook-setup.md | 76 + .../references/sut-analysis.md | 73 + .../references/sut-discovery.md | 229 +++ .../references/validating-claims.md | 81 + .../antithesis-skills-feedback/SKILL.md | 76 + .claude/skills/antithesis-triage/SKILL.md | 145 ++ .../antithesis-triage/references/logs.md | 380 +++++ .../references/properties.md | 115 ++ .../references/run-discovery.md | 61 + .../antithesis-triage/references/run-info.md | 25 + .claude/skills/antithesis-workload/SKILL.md | 171 ++ .../references/assertions.md | 135 ++ .../references/component-implementation.md | 49 + .../references/interesting-values.md | 89 + .../references/iteration.md | 37 + .../references/multi-test-directories.md | 135 ++ .../references/test-commands.md | 225 +++ 51 files changed, 12326 insertions(+) create mode 100644 .claude/skills/antithesis-agent-browser/SKILL.md create mode 100644 .claude/skills/antithesis-agent-browser/assets/antithesis-agent-browser.js create mode 100755 .claude/skills/antithesis-agent-browser/assets/download-logs-from-url.sh create mode 100644 .claude/skills/antithesis-agent-browser/references/error-reports.md create mode 100644 .claude/skills/antithesis-agent-browser/references/properties-from-ui.md create mode 100644 .claude/skills/antithesis-agent-browser/references/run-discovery-ui.md create mode 100644 .claude/skills/antithesis-agent-browser/references/setup-auth.md create mode 100644 .claude/skills/antithesis-debug/SKILL.md create mode 100644 .claude/skills/antithesis-debug/assets/antithesis-debug.js create mode 100755 .claude/skills/antithesis-debug/assets/download-mvd-log.sh create mode 100644 .claude/skills/antithesis-debug/assets/process-logs.py create mode 100644 .claude/skills/antithesis-debug/references/actions.md create mode 100644 .claude/skills/antithesis-debug/references/advanced-debugger.md create mode 100644 .claude/skills/antithesis-debug/references/common-inspections.md create mode 100644 .claude/skills/antithesis-debug/references/download-log.md create mode 100644 .claude/skills/antithesis-debug/references/notebook.md create mode 100644 .claude/skills/antithesis-debug/references/setup-session.md create mode 100644 .claude/skills/antithesis-debug/references/simplified-debugger.md create mode 100644 .claude/skills/antithesis-documentation/SKILL.md create mode 100644 .claude/skills/antithesis-launch/SKILL.md create mode 100644 .claude/skills/antithesis-query-logs/SKILL.md create mode 100644 .claude/skills/antithesis-query-logs/assets/antithesis-query-logs.js create mode 100755 .claude/skills/antithesis-query-logs/assets/build-url.py create mode 100644 .claude/skills/antithesis-query-logs/references/map.md create mode 100644 .claude/skills/antithesis-query-logs/references/query-builder.md create mode 100644 .claude/skills/antithesis-query-logs/references/results.md create mode 100644 .claude/skills/antithesis-query-logs/references/temporal-queries.md create mode 100644 .claude/skills/antithesis-research/SKILL.md create mode 100644 .claude/skills/antithesis-research/references/deployment-topology.md create mode 100644 .claude/skills/antithesis-research/references/faults.md create mode 100644 .claude/skills/antithesis-research/references/property-catalog.md create mode 100644 .claude/skills/antithesis-research/references/property-discovery.md create mode 100644 .claude/skills/antithesis-research/references/property-evaluation.md create mode 100644 .claude/skills/antithesis-research/references/scratchbook-artifacts.md create mode 100644 .claude/skills/antithesis-research/references/scratchbook-setup.md create mode 100644 .claude/skills/antithesis-research/references/sut-analysis.md create mode 100644 .claude/skills/antithesis-research/references/sut-discovery.md create mode 100644 .claude/skills/antithesis-research/references/validating-claims.md create mode 100644 .claude/skills/antithesis-skills-feedback/SKILL.md create mode 100644 .claude/skills/antithesis-triage/SKILL.md create mode 100644 .claude/skills/antithesis-triage/references/logs.md create mode 100644 .claude/skills/antithesis-triage/references/properties.md create mode 100644 .claude/skills/antithesis-triage/references/run-discovery.md create mode 100644 .claude/skills/antithesis-triage/references/run-info.md create mode 100644 .claude/skills/antithesis-workload/SKILL.md create mode 100644 .claude/skills/antithesis-workload/references/assertions.md create mode 100644 .claude/skills/antithesis-workload/references/component-implementation.md create mode 100644 .claude/skills/antithesis-workload/references/interesting-values.md create mode 100644 .claude/skills/antithesis-workload/references/iteration.md create mode 100644 .claude/skills/antithesis-workload/references/multi-test-directories.md create mode 100644 .claude/skills/antithesis-workload/references/test-commands.md diff --git a/.claude/skills/antithesis-agent-browser/SKILL.md b/.claude/skills/antithesis-agent-browser/SKILL.md new file mode 100644 index 0000000..0274428 --- /dev/null +++ b/.claude/skills/antithesis-agent-browser/SKILL.md @@ -0,0 +1,163 @@ +--- +name: antithesis-agent-browser +description: > + Authenticate `agent-browser` against an Antithesis tenant and read data from Antithesis web pages (triage reports, runs page, logs viewer, causality reports). Only invoke this skill when explicitly requested by another skill or the user. Requires interactive browser authentication and is unsuitable for fully headless workflows. +compatibility: agent-browser (https://github.com/vercel-labs/agent-browser) and jq. +metadata: + version: "2026-07-14 1f59c97" +--- + +# Antithesis agent-browser + +**Skill version:** `2026-07-14 1f59c97` + +A helper skill for reading data from authenticated Antithesis web pages +when the snouty API doesn't expose what's needed. Other skills +(`antithesis-triage`, `antithesis-debug`, `antithesis-query-logs`) delegate +here when they need an authenticated browser session. + +## Purpose + +- Handle interactive authentication to the user's Antithesis tenant with + minimal disruption. +- Load Antithesis web pages (reports, logs, runs, causality) and extract + data from them via an injected runtime. + +## When to use + +- When another skill needs information from an Antithesis page that snouty + cannot reliably provide. +- When the user or another skill needs to interact with an Antithesis web + page directly. + +## Prerequisites + +- DO NOT PROCEED if `agent-browser` is not installed. See `https://raw.githubusercontent.com/vercel-labs/agent-browser/refs/heads/main/README.md` for installation options. +- DO NOT PROCEED if `agent-browser` is older than version `v0.23.4`. You can upgrade with `agent-browser upgrade`. +- DO NOT PROCEED if `jq` is not installed. See `https://jqlang.org/download/` for installation options. + +**Reference files:** This skill's `references/` directory contains detailed guides for specific tasks. Do NOT read them all up front — only read a reference file when you are told to. Each reference file is mentioned by name at the point where it is needed. + +## General guidance + +- **Always ensure you are authenticated first.** +- **Use disposable sessions.** Generate a unique `SESSION` for each invocation of this skill. +- **Inject the runtime after navigation.** After every `open`, after link clicks that may change pages, and after reopening a report from a finding route, wait until `networkidle`, inject `assets/antithesis-agent-browser.js`, then use the matching `*.waitForReady()` method before continuing. +- **Never run `agent-browser` calls in parallel.** They are stateful with side effects. +- **Retry missing-runtime errors by reinjecting.** If a command fails because `window.__antithesisAgentBrowser` is undefined or missing, inject the runtime and rerun the same method. +- **Keep report evals on the main report view.** If you click into another page by accident, reopen the original report URL before using report queries again. + +## Session management with `agent-browser` + +`agent-browser` has two session variables: + +- `--session`: the name of a unique, isolated browser instance +- `--session-name`: auto-save/restore cookies by name + +Every invocation MUST use a unique `--session` value. Generate it once and reuse it whenever you see `$SESSION` referenced by this skill. + +```sh +SESSION=`antithesis-ab-$(date +%s)-$$` +``` + +Use `--session-name antithesis` on the FIRST `agent-browser` command that references a new `$SESSION`. This creates the session and restores saved cookies. Subsequent commands for the same `$SESSION` do not need `--session-name` — the session already exists. + +Close the live session when done. + +```sh +agent-browser --session $SESSION close +``` + +## Authentication + +Do NOT navigate to the home page just to check auth. Instead, navigate directly to your target URL (report, runs page, etc.) using the session-creation command: + +``` +agent-browser --session "$SESSION" --session-name antithesis open "$TARGET_URL" +agent-browser --session "$SESSION" wait --load networkidle +agent-browser --session "$SESSION" get url +``` + +If the URL starts with `https://$TENANT.antithesis.com` then you are authenticated. If it redirected to a login page, you need to authenticate — read `references/setup-auth.md`, which gates the interactive headed login on user confirmation. + +## Runtime injection + +This skill ships a runtime (`assets/antithesis-agent-browser.js`) that exposes helper methods on `window.__antithesisAgentBrowser` for reading data from Antithesis pages. Inject it after navigation completes: + +```bash +cat assets/antithesis-agent-browser.js \ + | agent-browser --session "$SESSION" eval --stdin +``` + +Call its methods with `agent-browser eval`: + +```bash +agent-browser --session "$SESSION" eval \ + "window.__antithesisAgentBrowser.report.getRunMetadata()" +``` + +`agent-browser eval` awaits Promises automatically, so async and sync methods use the same call pattern. + +**Error handling:** Runtime methods throw on error, which causes `agent-browser eval` to return a non-zero exit code. Check the exit code to detect failures — no output parsing required. The error message describes what went wrong (e.g. wrong page, element not found, timeout). + +If `window.__antithesisAgentBrowser` is missing, reinject `assets/antithesis-agent-browser.js` and retry. + +NEVER run `agent-browser` calls in parallel. They are stateful calls with side effects; parallel calls can break or return confusing results. + +## Navigation and loading + +Each Antithesis page loads content asynchronously. After navigation to any Antithesis page, follow this pattern: + +First, wait for networkidle: + +```sh +agent-browser --session "$SESSION" wait --load networkidle +``` + +Then, check the url to see if you got redirected to an authentication page: + +```sh +agent-browser --session "$SESSION" get url +``` + +If you hit an authentication page, stop and reauthenticate before continuing. + +Then, inject the runtime: + +```bash +cat assets/antithesis-agent-browser.js \ + | agent-browser --session "$SESSION" eval --stdin +``` + +Finally, eval the page-specific wait function to wait for all asynchronous chunks to finish loading: + +- Report page: `window.__antithesisAgentBrowser.report.waitForReady()` +- Logs page: `window.__antithesisAgentBrowser.logs.waitForReady()` +- Runs page: `window.__antithesisAgentBrowser.runs.waitForReady()` + +Each wait method polls for up to 60 seconds by default. On success it returns `{ attempts, waitedMs }`. On timeout, the method **throws** causing `agent-browser eval` to return a non-zero exit code. + +Use the lower-level boolean checks when you need a one-shot probe: + +- Report page: `window.__antithesisAgentBrowser.report.loadingFinished()` +- Logs page: `window.__antithesisAgentBrowser.logs.loadingFinished()` +- Runs page: `window.__antithesisAgentBrowser.runs.loadingFinished()` + +If a page still does not become ready, inspect status: + +- Report page: `window.__antithesisAgentBrowser.report.loadingStatus()` +- Logs page: `window.__antithesisAgentBrowser.logs.loadingStatus()` +- Runs page: `window.__antithesisAgentBrowser.runs.loadingStatus()` + +## Handling error reports + +After every report `waitForReady()` call, check `result.error`. If it is present, read `references/error-reports.md` for the error-report workflow. + +## What this skill can do + +1. **Authenticate to the tenant.** Read `references/setup-auth.md` for the interactive login flow. +2. **Discover runs from the runs page.** Read `references/run-discovery-ui.md`. Prefer `snouty runs list` whenever possible — only fall back to the UI when the API is unavailable or doesn't expose what you need. +3. **Read properties from a triage report.** Read `references/properties-from-ui.md`. Prefer `snouty runs --json properties` whenever possible. +4. **Look up a `run_id` from a triage-report URL** that cannot be matched against `snouty runs list`. The `run_id` is rendered at the bottom of the main report page (`report_id → run_id` is not currently exposed by the API). +5. **Download an error log from a failed run's report page.** See `references/error-reports.md`. +6. **Read a causality report.** Ask the user for the causality report URL. The main signal is the bug-probability trajectory in the logs leading up to the bug moment. diff --git a/.claude/skills/antithesis-agent-browser/assets/antithesis-agent-browser.js b/.claude/skills/antithesis-agent-browser/assets/antithesis-agent-browser.js new file mode 100644 index 0000000..3f53e2d --- /dev/null +++ b/.claude/skills/antithesis-agent-browser/assets/antithesis-agent-browser.js @@ -0,0 +1,1492 @@ +(function () { + var VERSION = "3.0.0"; + + function clean(text) { + return (text || "").replace(/\s+/g, " ").trim(); + } + + function wait(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, ms); + }); + } + + function abort(details) { + var msg = + typeof details === "string" + ? details + : (details && details.error) || JSON.stringify(details); + var err = new Error(msg); + err.details = details; + throw err; + } + + function isVisible(el) { + if (!el || typeof el.getBoundingClientRect !== "function") return false; + + var rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + + var style = window.getComputedStyle(el); + return style.display !== "none" && style.visibility !== "hidden"; + } + + function click(el) { + if (!el) return false; + + ["pointerdown", "mousedown", "mouseup", "click"].forEach(function (type) { + el.dispatchEvent( + new MouseEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + view: window, + }), + ); + }); + + return true; + } + + function ownText(el) { + if (!el) return ""; + + // Tooltip-heavy cells often mix labels and nested metadata; keep only direct text. + return clean( + Array.from(el.childNodes) + .filter(function (node) { + return node.nodeType === Node.TEXT_NODE; + }) + .map(function (node) { + return node.textContent; + }) + .join(" "), + ); + } + + function lastTextNode(el) { + if (!el) return ""; + + // Log rows append the visible value as the last text node after tooltip elements. + var nodes = el.childNodes; + for (var i = nodes.length - 1; i >= 0; i--) { + if (nodes[i].nodeType === Node.TEXT_NODE && clean(nodes[i].textContent)) { + return clean(nodes[i].textContent); + } + } + + return ""; + } + + function lastText(el) { + var direct = lastTextNode(el); + if (direct) return direct; + // Only fall back to full textContent when no child elements exist; + // otherwise the tooltip label (e.g. "event.container") leaks through. + if (el && !el.querySelector("*")) return clean(el.textContent); + return ""; + } + + function parseItemCount(text) { + var matches = Array.from((text || "").matchAll(/(\d[\d,]*)\s*items?\b/gi)); + return matches.length + ? Number(matches[matches.length - 1][1].replace(/,/g, "")) + : null; + } + + function hasLoadingText(text) { + return /loading(?:\.\.\.)?/i.test(text || ""); + } + + // --------------------------------------------------------------------------- + // Error detection + // --------------------------------------------------------------------------- + + function detectSetupError() { + // Setup-failure reports replace the normal Properties / Findings / Utilization + // sections with a single "Error" section whose heading is an

. + var sections = document.querySelectorAll(".section_container.top_section"); + + for (var i = 0; i < sections.length; i++) { + var heading = sections[i].querySelector("h3"); + if (!heading || clean(heading.textContent) !== "Error") continue; + + var summary = sections[i].querySelector(".section_summary"); + var content = sections[i].querySelector(".section_content"); + + // The validation error message lives in a
 inside the content area.
+      // Fall back to the full section text (trimmed) if no 
 is found.
+      var pre = content && content.querySelector("pre");
+      var details = clean(
+        pre ? pre.textContent : content && content.textContent,
+      );
+
+      return {
+        type: "setup_error",
+        summary: clean(summary && summary.textContent),
+        details: details.length > 2000 ? details.substring(0, 2000) : details,
+      };
+    }
+
+    return null;
+  }
+
+  function detectRuntimeError() {
+    // Runtime errors render a prominent banner at the top of the page using the
+    // GeneralErrorNew component.  The rest of the report may partially load but
+    // one or more sections (typically Findings) will be stuck on "Loading...".
+    var el = document.querySelector(".GeneralErrorNew");
+    if (!el || !isVisible(el)) return null;
+
+    var title = el.querySelector(".GeneralErrorNew__title");
+    var text = el.querySelector(".GeneralErrorNew__text");
+    var details = clean(text && text.textContent);
+
+    return {
+      type: "runtime_error",
+      summary: clean(title && title.textContent) || "Error",
+      details: details.length > 2000 ? details.substring(0, 2000) : details,
+    };
+  }
+
+  function detectError() {
+    return detectSetupError() || detectRuntimeError() || null;
+  }
+
+  function requireReportPage() {
+    if (!/^\/report\//.test(window.location.pathname)) {
+      abort({ error: "expected main report view", url: window.location.href });
+    }
+
+    if (/\/findings?\//.test(window.location.hash)) {
+      abort({
+        error: "expected main report view, not finding hash route",
+        url: window.location.href,
+      });
+    }
+  }
+
+  function requireLogsPage() {
+    if (
+      window.location.pathname !== "/search" ||
+      !/[?&]get_logs=true\b/.test(window.location.search)
+    ) {
+      abort({
+        error: "expected selected-event logs view",
+        url: window.location.href,
+      });
+    }
+  }
+
+  function requireRunsPage() {
+    if (window.location.pathname !== "/runs") {
+      abort({ error: "expected runs page", url: window.location.href });
+    }
+  }
+
+  function findSectionByHeading(heading) {
+    return Array.from(document.querySelectorAll("section")).find(
+      function (section) {
+        var h = section.querySelector("h1, h2, h3, h4, h5, h6");
+        return clean(h && h.textContent) === heading;
+      },
+    );
+  }
+
+  function visibleCount(selector) {
+    return Array.from(document.querySelectorAll(selector)).filter(isVisible)
+      .length;
+  }
+
+  function sectionInfo(section) {
+    return {
+      exists: !!section,
+      text: clean(section && section.textContent),
+      hasLoadingText: hasLoadingText(section && section.textContent),
+    };
+  }
+
+  function sectionLooksLoaded(section) {
+    if (!section || !isVisible(section)) return false;
+
+    var text = clean(section.textContent);
+    return !!(text && !hasLoadingText(text));
+  }
+
+  function allPropertyContainers() {
+    return Array.from(document.querySelectorAll(".property-container"));
+  }
+
+  function visiblePropertyContainers() {
+    return allPropertyContainers().filter(isVisible);
+  }
+
+  function stateFromStatus(el) {
+    var classes = el ? el.className : "";
+    return classes.includes("_passed")
+      ? "passed"
+      : classes.includes("_failed")
+        ? "failed"
+        : classes.includes("_unfound")
+          ? "unfound"
+          : "unknown";
+  }
+
+  function containerStatus(container) {
+    return stateFromStatus(
+      container.querySelector(":scope > .property .property__status"),
+    );
+  }
+
+  function nameOf(container) {
+    var label = container.querySelector(
+      ":scope > .property .property__name_label",
+    );
+    return clean(label && label.textContent);
+  }
+
+  function directChildren(container) {
+    return container.querySelectorAll(
+      ":scope > .property__details > .property-container",
+    ).length;
+  }
+
+  function isLeaf(container) {
+    return directChildren(container) === 0;
+  }
+
+  function isGroup(container) {
+    return directChildren(container) > 0;
+  }
+
+  function isExpanded(container) {
+    return !!container.querySelector(
+      ":scope > .property .property__expander._expanded, :scope > .property__details._unfolded",
+    );
+  }
+
+  function expanderButton(container) {
+    return container.querySelector(
+      ":scope > .property .property__expander-button",
+    );
+  }
+
+  function groupPath(container) {
+    var path = [];
+    var parent = container.parentElement
+      ? container.parentElement.closest(".property-container")
+      : null;
+
+    while (parent) {
+      var name = nameOf(parent);
+      if (name) path.unshift(name);
+      parent = parent.parentElement
+        ? parent.parentElement.closest(".property-container")
+        : null;
+    }
+
+    return path;
+  }
+
+  function tabLabelText(tab) {
+    return clean(tab && tab.textContent).toLowerCase();
+  }
+
+  function tabByPattern(pattern) {
+    return Array.from(document.querySelectorAll("a-tab")).find(function (tab) {
+      return pattern.test(tabLabelText(tab));
+    });
+  }
+
+  function countFromTab(tab) {
+    var match = tabLabelText(tab).match(/(\d+)/);
+    return match ? Number(match[1]) : null;
+  }
+
+  async function waitForReady(checkFn, detailsFn, options) {
+    var timeoutMs =
+      options && typeof options.timeoutMs === "number"
+        ? options.timeoutMs
+        : 60000;
+    var intervalMs =
+      options && typeof options.intervalMs === "number"
+        ? options.intervalMs
+        : 1000;
+    var startedAt = Date.now();
+    var deadline = startedAt + timeoutMs;
+    var attempts = 0;
+
+    while (Date.now() <= deadline) {
+      attempts += 1;
+
+      if (checkFn()) {
+        return {
+          attempts: attempts,
+          waitedMs: Date.now() - startedAt,
+        };
+      }
+
+      if (Date.now() + intervalMs > deadline) break;
+      await wait(intervalMs);
+    }
+
+    abort({
+      error: "timed out waiting for page to be ready",
+      attempts: attempts,
+      waitedMs: Date.now() - startedAt,
+      details: detailsFn ? detailsFn() : null,
+    });
+  }
+
+  async function expandAllFindings() {
+    var sections = Array.from(
+      document.querySelectorAll("details.findings_section_details"),
+    );
+    var opened = 0;
+    sections.forEach(function (section) {
+      if (!section.open) {
+        section.open = true;
+        opened++;
+      }
+    });
+    if (opened > 0) await wait(300);
+  }
+
+  async function expandAllSections() {
+    var settleMs = 300;
+    var maxPasses = 10;
+
+    for (var pass = 0; pass < maxPasses; pass++) {
+      var collapsed = Array.from(
+        document.querySelectorAll(".section_container"),
+      ).filter(function (sc) {
+        var expander = sc.querySelector(
+          ":scope > .section_header .expander.section_expander",
+        );
+        return expander && !expander.classList.contains("_expanded");
+      });
+
+      if (collapsed.length === 0) break;
+
+      for (var i = 0; i < collapsed.length; i++) {
+        var btn = collapsed[i].querySelector(
+          ":scope > .section_header .section_title_button",
+        );
+        if (btn) click(btn);
+      }
+
+      await wait(settleMs);
+    }
+  }
+
+  async function expandAllProperties() {
+    requireReportPage();
+
+    // Expand all property containers in up to 32 passes (groups and leaves).
+    // Scroll through the properties section to ensure off-screen containers
+    // become visible before attempting to expand them.
+    for (var i = 0; i < 32; i++) {
+      var changed = false;
+
+      var buttons = document.querySelectorAll(".property__expander-button");
+      for (var j = 0; j < buttons.length; j++) {
+        var btn = buttons[j];
+        var container = btn.closest(".property-container");
+        if (!container || isExpanded(container)) continue;
+
+        // Scroll into view if not visible, then expand
+        if (!isVisible(container)) {
+          container.scrollIntoView({ block: "center" });
+          await wait(50);
+        }
+        if (click(btn)) changed = true;
+      }
+
+      if (!changed) break;
+      await wait(250);
+    }
+  }
+
+  function exampleCounts(container) {
+    // Try the container's own text first
+    var text = clean(container.textContent);
+    var match = text.match(
+      /([\d,]+)\s+passing\s+example.*?([\d,]+)\s+failing\s+example/,
+    );
+    if (match) return { passingCount: match[1], failingCount: match[2] };
+
+    // If not found, look specifically in the run summary element
+    var summary = container.querySelector(".selected_property_run_summary");
+    if (summary) {
+      var summaryText = clean(summary.textContent);
+      match = summaryText.match(
+        /([\d,]+)\s+passing\s+example.*?([\d,]+)\s+failing\s+example/,
+      );
+      if (match) return { passingCount: match[1], failingCount: match[2] };
+    }
+
+    return { passingCount: null, failingCount: null };
+  }
+
+  // Assumes leaf properties are already expanded (waitForReady expands all
+  // sections and properties on load). Pass/fail example counts are only
+  // present in the DOM after expansion.
+  function visibleLeafProperties(filterStatus) {
+    return visiblePropertyContainers()
+      .filter(function (container) {
+        if (!isLeaf(container) || !nameOf(container)) return false;
+        return !filterStatus || containerStatus(container) === filterStatus;
+      })
+      .map(function (container) {
+        var counts = exampleCounts(container);
+        return {
+          group: groupPath(container),
+          name: nameOf(container),
+          status: containerStatus(container),
+          passingCount: counts.passingCount,
+          failingCount: counts.failingCount,
+        };
+      });
+  }
+
+  function exampleRowElements(container) {
+    return container.querySelectorAll(
+      ":scope > .property__details .examples_table__row",
+    );
+  }
+
+  function examplesRows(container) {
+    return exampleRowElements(container).length;
+  }
+
+  function cleanLogOutput(text) {
+    return clean(text).replace(/^event\.output_text\s*/i, "");
+  }
+
+  function extractLogOutput(varyingPart) {
+    var output =
+      varyingPart && varyingPart.querySelector(".event__output_text");
+    var direct = cleanLogOutput(lastTextNode(output));
+    if (direct) return direct;
+    return cleanLogOutput(output && output.textContent);
+  }
+
+  function extractLogDirectText(varyingPart) {
+    return clean(
+      Array.from((varyingPart && varyingPart.childNodes) || [])
+        .filter(function (node) {
+          return node.nodeType === Node.TEXT_NODE;
+        })
+        .map(function (node) {
+          return node.textContent;
+        })
+        .join(" "),
+    );
+  }
+
+  function extractLogDetails(ev) {
+    // Assertion rows and regular log rows render their useful text differently.
+    var assertion = ev.querySelector(".sdk-assertion__meta");
+    var assertionText = clean(assertion && assertion.textContent);
+    if (assertionText) {
+      return {
+        assertionText: assertionText,
+        directText: "",
+        outputText: "",
+        text: assertionText,
+      };
+    }
+
+    var varyingPart = ev.querySelector(".event__varying-part");
+    var directText = extractLogDirectText(varyingPart);
+    var outputText = extractLogOutput(varyingPart);
+    var text = outputText || directText;
+
+    if (directText && outputText && directText !== outputText) {
+      text = directText + " | " + outputText;
+    }
+
+    return {
+      assertionText: "",
+      directText: directText,
+      outputText: outputText,
+      text: text,
+    };
+  }
+
+  function serializeLogEvent(ev) {
+    var details = extractLogDetails(ev);
+
+    return {
+      vtime: lastText(ev.querySelector(".event__vtime")),
+      container: lastText(ev.querySelector(".event__container")),
+      source: lastText(ev.querySelector(".event__source_name")),
+      text: details.text,
+      directText: details.directText,
+      outputText: details.outputText,
+      highlighted:
+        ev.classList.contains("_emphasized_blue") ||
+        ev.classList.contains("_emphasized"),
+    };
+  }
+
+  function errorSection() {
+    return findSectionByHeading("Error");
+  }
+
+  function inlineErrorLogWrappers() {
+    var section = errorSection();
+    if (!section) return [];
+
+    return Array.from(
+      section.querySelectorAll(".sequence_printer_wrapper"),
+    ).filter(isVisible);
+  }
+
+  function requireInlineErrorLogs() {
+    requireReportPage();
+
+    var err = detectError();
+    if (!err) {
+      abort({
+        error: "expected error report with inline logs",
+        url: window.location.href,
+      });
+    }
+
+    var wrappers = inlineErrorLogWrappers();
+    if (!wrappers.length) {
+      abort({
+        error: "no inline error log panes found",
+        errorType: err.type,
+        url: window.location.href,
+      });
+    }
+  }
+
+  function inlineErrorLogViews() {
+    return inlineErrorLogWrappers().map(function (wrapper, index) {
+      var counterEl = wrapper.querySelector(".sequence_toolbar__items-counter");
+      var counterText = clean(counterEl && counterEl.textContent);
+      var events = Array.from(wrapper.querySelectorAll(".event"));
+      var firstEvent = events.length ? serializeLogEvent(events[0]) : null;
+
+      return {
+        index: index,
+        itemCount: parseItemCount(counterText),
+        visibleEvents: events.filter(isVisible).length,
+        firstEvent: firstEvent,
+      };
+    });
+  }
+
+  function tooltipMap(tooltip) {
+    if (!tooltip) return {};
+
+    // Runs-page tooltips reuse a few DOM layouts; normalize them into key/value maps.
+    var out = {};
+    var rows = Array.from(tooltip.children);
+
+    if (rows.length === 1 && rows[0].children.length) {
+      rows = Array.from(rows[0].children);
+    }
+
+    rows.forEach(function (row) {
+      var keyEl =
+        row.querySelector(".runs_table_muted_tooltip_text") ||
+        row.querySelector(".runs_table_name_column_tooltip_key");
+      var valueEl = row.querySelector(".runs_table_tooltip_text");
+      var key = clean(keyEl && keyEl.textContent).replace(/:$/, "");
+      var value = clean(
+        valueEl
+          ? valueEl.textContent
+          : clean(row.textContent).replace(
+              clean(keyEl && keyEl.textContent),
+              "",
+            ),
+      );
+      var parsed = clean(row.textContent).match(/^([^:]+):\s*(.*)$/);
+
+      if (!key && parsed) key = clean(parsed[1]);
+      if (!value && parsed) value = clean(parsed[2]);
+      if (key) out[key] = value;
+    });
+
+    return out;
+  }
+
+  function pairMap(container) {
+    if (!container) return {};
+
+    // Utilization cells render as alternating key/value spans.
+    var spans = Array.from(container.querySelectorAll("span"));
+    var out = {};
+    for (var i = 0; i + 1 < spans.length; i += 2) {
+      var key = clean(spans[i].textContent).replace(/:$/, "");
+      var value = clean(spans[i + 1].textContent);
+      if (key) out[key] = value;
+    }
+    return out;
+  }
+
+  function findingsMap(container) {
+    if (!container) return {};
+
+    // Findings cells collapse badge text into single spans like "3 new".
+    var out = {};
+    Array.from(container.querySelectorAll("span")).forEach(function (span) {
+      var match = clean(span.textContent).match(/^(\S+)\s+(.+)$/);
+      if (match) out[match[2].toLowerCase()] = match[1];
+    });
+    return out;
+  }
+
+  function actionUrl(cell, label) {
+    var link = Array.from(cell.querySelectorAll("a")).find(function (anchor) {
+      return clean(anchor.textContent) === label;
+    });
+    return link ? link.href : null;
+  }
+
+  function parseRunRow(row) {
+    var nameCell = row.querySelector('[cell-identifier="name"]');
+    var creatorSource = row.querySelector('[cell-identifier="creator_source"]');
+    var creatorName = row.querySelector('[cell-identifier="creator_name"]');
+    var dateCell = row.querySelector('[cell-identifier="date"]');
+    var timeCell = row.querySelector('[cell-identifier="time"]');
+    var statusCell = row.querySelector('[cell-identifier="status"]');
+    var durationCell = row.querySelector('[cell-identifier="duration"]');
+    var findingsCell = row.querySelector(".runs_table_run_findings");
+    var utilizationCell = row.querySelector(".runs_table_utilization");
+    var actionCell = row.querySelector(
+      "a-cell.table_left_most_right_pinned_col",
+    );
+    var nameTooltip = nameCell
+      ? nameCell.closest("a-cell").querySelector("a-tooltip")
+      : null;
+    var creatorTooltip = creatorSource
+      ? creatorSource.closest("a-cell").querySelector("a-tooltip")
+      : null;
+    var nameMeta = tooltipMap(nameTooltip);
+    var creatorMeta = tooltipMap(creatorTooltip);
+
+    var nameMutedEl =
+      nameCell && nameCell.querySelector(".runs_table_muted_tooltip_text");
+    var nameTooltipTextEl =
+      nameCell && nameCell.querySelector(".runs_table_tooltip_text");
+
+    return {
+      name:
+        nameMeta.Name ||
+        clean(nameMutedEl && nameMutedEl.textContent) ||
+        clean(nameCell && nameCell.textContent),
+      description:
+        nameMeta.Description ||
+        clean(nameTooltipTextEl && nameTooltipTextEl.textContent),
+      creatorSource: clean(creatorSource && creatorSource.textContent),
+      creatorName:
+        ownText(creatorName && creatorName.querySelector("div")) ||
+        clean(creatorName && creatorName.textContent),
+      creatorCategory: creatorMeta.Category || "",
+      cadence: creatorMeta.Cadence || "",
+      commit: creatorMeta["Commit hash"] || "",
+      repository: creatorMeta.Repository || "",
+      date: clean(dateCell && dateCell.textContent),
+      time: clean(timeCell && timeCell.textContent),
+      status: clean(statusCell && statusCell.textContent),
+      duration: clean(durationCell && durationCell.textContent),
+      findings: findingsMap(findingsCell),
+      utilization: pairMap(utilizationCell),
+      triageUrl: actionCell ? actionUrl(actionCell, "Triage results") : null,
+      logsUrl: actionCell ? actionUrl(actionCell, "Explore Logs") : null,
+      hasTriageResults: !!(
+        actionCell && actionUrl(actionCell, "Triage results")
+      ),
+    };
+  }
+
+  function runKey(run) {
+    // Prefer stable URLs; fall back to visible row content for in-progress runs.
+    return (
+      run.triageUrl ||
+      run.logsUrl ||
+      [run.name, run.description, run.date, run.time].join(" | ")
+    );
+  }
+
+  function getAllProperties() {
+    requireReportPage();
+
+    var tab = tabByPattern(/\ball\b/);
+    var properties = visibleLeafProperties();
+    var counts = properties.reduce(function (acc, property) {
+      acc[property.status] = (acc[property.status] || 0) + 1;
+      return acc;
+    }, {});
+
+    return {
+      expectedCount: countFromTab(tab),
+      counts: counts,
+      properties: properties,
+    };
+  }
+
+  function parseExampleRow(row, index) {
+    var example = row.querySelector(".example_failing, .example_passing");
+    var timeCell = row.querySelectorAll("td")[1];
+
+    return {
+      index: index,
+      status: example ? example.className.replace("example_", "") : "",
+      time: timeCell && clean(timeCell.textContent),
+    };
+  }
+
+  function examplesForContainer(container) {
+    return Array.from(exampleRowElements(container)).map(function (row, i) {
+      return parseExampleRow(row, i);
+    });
+  }
+
+  function getExampleLogsUrl(propertyName, exampleIndex) {
+    requireReportPage();
+
+    var idx = typeof exampleIndex === "number" ? exampleIndex : 0;
+    var containers = visiblePropertyContainers();
+    var target = null;
+
+    for (var i = 0; i < containers.length; i++) {
+      var container = containers[i];
+      if (isGroup(container)) continue;
+      if (nameOf(container) === propertyName) {
+        target = container;
+        break;
+      }
+    }
+
+    if (!target) {
+      abort({ error: "property not found", propertyName: propertyName });
+    }
+
+    var rows = Array.from(exampleRowElements(target));
+
+    if (idx < 0 || idx >= rows.length) {
+      abort({
+        error: "example index out of range",
+        propertyName: propertyName,
+        exampleIndex: idx,
+        availableExamples: rows.length,
+      });
+    }
+
+    var row = rows[idx];
+    var link = row.querySelector("a[href*='search']");
+
+    return {
+      propertyName: propertyName,
+      exampleIndex: idx,
+      logsUrl: link ? link.href : null,
+    };
+  }
+
+  function getPropertyExamples() {
+    requireReportPage();
+
+    var properties = visiblePropertyContainers()
+      .filter(function (container) {
+        return (
+          !isGroup(container) &&
+          nameOf(container) &&
+          examplesRows(container) > 0
+        );
+      })
+      .map(function (container) {
+        return {
+          group: groupPath(container),
+          name: nameOf(container),
+          status: containerStatus(container),
+          examples: examplesForContainer(container),
+        };
+      });
+
+    return {
+      properties: properties,
+      totalExamples: properties.reduce(function (sum, property) {
+        return sum + property.examples.length;
+      }, 0),
+    };
+  }
+
+  var reportApi = {
+    loadingFinished: function () {
+      var titleEl = document.querySelector(".branded_title");
+      var metadataEl = document.querySelector(".branded_metadata");
+      var title = clean(titleEl && titleEl.textContent);
+      var metadata = clean(metadataEl && metadataEl.textContent);
+
+      // Error reports are "done loading" even though normal sections may be
+      // missing or stuck.  Require at least the title to have rendered so the
+      // runtime has something to extract.
+      if (detectError() && isVisible(titleEl) && title) {
+        return true;
+      }
+
+      var environmentSection = findSectionByHeading("Environment");
+      var utilizationSection = findSectionByHeading("Utilization");
+      var propertiesSection = document.querySelector(
+        "section.section_properties:not(.section_findings)",
+      );
+      var environmentImages = visibleCount(
+        ".presentation_environment__source_image",
+      );
+      var utilizationMetric = clean(
+        document.querySelector(".utilization-summary__metric") &&
+          document.querySelector(".utilization-summary__metric").textContent,
+      );
+      var propertyTabs = visibleCount("a-tab");
+      var propertyContainers = visibleCount(".property-container");
+      var propertiesText = clean(
+        propertiesSection && propertiesSection.textContent,
+      );
+      var findingsSection = document.querySelector("section.section_findings");
+      var findingsText = clean(findingsSection && findingsSection.textContent);
+      var findingsDetails =
+        findingsSection && isVisible(findingsSection)
+          ? findingsSection.querySelectorAll("details.findings_section_details")
+              .length
+          : 0;
+      var findingLinks =
+        findingsSection && isVisible(findingsSection)
+          ? findingsSection.querySelectorAll(
+              'a[href*="/finding/"], a[href*="/findings/"]',
+            ).length
+          : 0;
+      var findingToggles =
+        findingsSection && isVisible(findingsSection)
+          ? findingsSection.querySelectorAll(
+              'input[name="include_manual"], input[name="show_ongoing"]',
+            ).length
+          : 0;
+      var findingsLoadedText =
+        /no findings|show suppressions|show ongoing findings|open triage report for this run|look into all findings/i.test(
+          findingsText,
+        );
+      var environmentLoaded = !!(
+        environmentImages > 0 || sectionLooksLoaded(environmentSection)
+      );
+      var utilizationLoaded = !!(
+        utilizationMetric || sectionLooksLoaded(utilizationSection)
+      );
+      var propertiesLoaded = !!(
+        propertiesSection &&
+        isVisible(propertiesSection) &&
+        propertyTabs >= 2 &&
+        propertyContainers > 0 &&
+        !hasLoadingText(propertiesText)
+      );
+      var findingsLoaded =
+        !!findingsSection &&
+        isVisible(findingsSection) &&
+        !hasLoadingText(findingsText) &&
+        (findingsDetails > 0 ||
+          findingLinks > 0 ||
+          findingToggles >= 2 ||
+          findingsLoadedText);
+
+      return !!(
+        isVisible(titleEl) &&
+        isVisible(metadataEl) &&
+        title &&
+        metadata &&
+        environmentLoaded &&
+        utilizationLoaded &&
+        propertiesLoaded &&
+        findingsLoaded
+      );
+    },
+
+    sectionReadiness: function () {
+      requireReportPage();
+
+      var err = detectError();
+      var environmentSection = findSectionByHeading("Environment");
+      var utilizationSection = findSectionByHeading("Utilization");
+      var propertiesSection = document.querySelector(
+        "section.section_properties:not(.section_findings)",
+      );
+      var findingsSection = document.querySelector("section.section_findings");
+
+      var environmentImages = visibleCount(
+        ".presentation_environment__source_image",
+      );
+      var utilizationMetric = clean(
+        document.querySelector(".utilization-summary__metric") &&
+          document.querySelector(".utilization-summary__metric").textContent,
+      );
+      var propertyTabs = visibleCount("a-tab");
+      var propertyContainers = visibleCount(".property-container");
+      var propertiesText = clean(
+        propertiesSection && propertiesSection.textContent,
+      );
+      var findingsText = clean(findingsSection && findingsSection.textContent);
+      var findingsDetails =
+        findingsSection && isVisible(findingsSection)
+          ? findingsSection.querySelectorAll("details.findings_section_details")
+              .length
+          : 0;
+      var findingLinks =
+        findingsSection && isVisible(findingsSection)
+          ? findingsSection.querySelectorAll(
+              'a[href*="/finding/"], a[href*="/findings/"]',
+            ).length
+          : 0;
+      var findingToggles =
+        findingsSection && isVisible(findingsSection)
+          ? findingsSection.querySelectorAll(
+              'input[name="include_manual"], input[name="show_ongoing"]',
+            ).length
+          : 0;
+      var findingsLoadedText =
+        /no findings|show suppressions|show ongoing findings|open triage report for this run|look into all findings/i.test(
+          findingsText,
+        );
+
+      function status(loaded, section) {
+        if (loaded) return "ok";
+        if (err && section && hasLoadingText(section.textContent))
+          return "error";
+        if (err) return "error";
+        return "not_loaded";
+      }
+
+      var environmentLoaded = !!(
+        environmentImages > 0 || sectionLooksLoaded(environmentSection)
+      );
+      var utilizationLoaded = !!(
+        utilizationMetric || sectionLooksLoaded(utilizationSection)
+      );
+      var propertiesLoaded = !!(
+        propertiesSection &&
+        isVisible(propertiesSection) &&
+        propertyTabs >= 2 &&
+        propertyContainers > 0 &&
+        !hasLoadingText(propertiesText)
+      );
+      var findingsLoaded =
+        !!findingsSection &&
+        isVisible(findingsSection) &&
+        !hasLoadingText(findingsText) &&
+        (findingsDetails > 0 ||
+          findingLinks > 0 ||
+          findingToggles >= 2 ||
+          findingsLoadedText);
+
+      return {
+        environment: status(environmentLoaded, environmentSection),
+        utilization: status(utilizationLoaded, utilizationSection),
+        properties: status(propertiesLoaded, propertiesSection),
+        findings: status(findingsLoaded, findingsSection),
+      };
+    },
+
+    waitForReady: async function (options) {
+      var result = await waitForReady(
+        function () {
+          return reportApi.loadingFinished();
+        },
+        function () {
+          return reportApi.loadingStatus();
+        },
+        options,
+      );
+
+      var err = detectError();
+      if (err) result.error = err;
+
+      // Per-section status so agents know which methods will work.
+      var sectionStatus = reportApi.sectionReadiness();
+      result.sections = sectionStatus;
+
+      await expandAllSections();
+      if (sectionStatus.properties === "ok") await expandAllProperties();
+      if (sectionStatus.findings === "ok") await expandAllFindings();
+      return result;
+    },
+
+    loadingStatus: function () {
+      requireReportPage();
+
+      var environmentSection = findSectionByHeading("Environment");
+      var utilizationSection = findSectionByHeading("Utilization");
+      var propertiesSection = document.querySelector(
+        "section.section_properties:not(.section_findings)",
+      );
+      var findingsSection = document.querySelector("section.section_findings");
+      var titleEl = document.querySelector(".branded_title");
+      var metadataEl = document.querySelector(".branded_metadata");
+      var metricEl = document.querySelector(".utilization-summary__metric");
+
+      return {
+        title: clean(titleEl && titleEl.textContent),
+        metadata: clean(metadataEl && metadataEl.textContent),
+        readyState: document.readyState,
+        environmentImages: document.querySelectorAll(
+          ".presentation_environment__source_image",
+        ).length,
+        utilizationMetric: clean(metricEl && metricEl.textContent),
+        propertyTabs: document.querySelectorAll("a-tab").length,
+        propertyContainers: document.querySelectorAll(".property-container")
+          .length,
+        findingsDetails: findingsSection
+          ? findingsSection.querySelectorAll("details.findings_section_details")
+              .length
+          : 0,
+        findingLinks: findingsSection
+          ? findingsSection.querySelectorAll(
+              'a[href*="/finding/"], a[href*="/findings/"]',
+            ).length
+          : 0,
+        findingToggles: findingsSection
+          ? findingsSection.querySelectorAll(
+              'input[name="include_manual"], input[name="show_ongoing"]',
+            ).length
+          : 0,
+        findingsText: clean(findingsSection && findingsSection.textContent),
+        error: detectError(),
+        sections: {
+          environment: sectionInfo(environmentSection),
+          utilization: sectionInfo(utilizationSection),
+          properties: sectionInfo(propertiesSection),
+          findings: sectionInfo(findingsSection),
+        },
+      };
+    },
+
+    getError: function () {
+      requireReportPage();
+      return detectError();
+    },
+
+    getInlineErrorLogViews: function () {
+      requireInlineErrorLogs();
+      return inlineErrorLogViews();
+    },
+
+    getRunMetadata: function () {
+      requireReportPage();
+
+      var titleEl = document.querySelector(".branded_title");
+      var metadataEl = document.querySelector(".branded_metadata");
+      var title = clean(titleEl && titleEl.textContent);
+      var metadataText = clean(metadataEl && metadataEl.textContent);
+      var metadataMatch = metadataText.match(
+        /^Conducted on\s+(.+?)(?:\s*Source:\s*(.+))?$/,
+      );
+
+      var metricKeys = {
+        "Test hours": "test_hours",
+        "Wall clock": "wall_clock",
+      };
+      var metrics = document.querySelectorAll(".utilization-summary__metric");
+      var utilization = {};
+      metrics.forEach(function (m) {
+        var parts = clean(m.textContent).split(":");
+        if (parts.length !== 2) return;
+        var key = metricKeys[parts[0].trim()];
+        if (key) utilization[key] = parts[1].trim();
+      });
+
+      return {
+        title: title,
+        metadata: metadataText,
+        conductedOn: metadataMatch ? metadataMatch[1].trim() : "",
+        source:
+          metadataMatch && metadataMatch[2] ? metadataMatch[2].trim() : "",
+        test_hours: utilization.test_hours || null,
+        wall_clock: utilization.wall_clock || null,
+      };
+    },
+
+    getEnvironmentSourceImages: function () {
+      requireReportPage();
+
+      return Array.from(
+        document.querySelectorAll(".presentation_environment__source_image"),
+      ).map(function (img) {
+        var title = img.querySelector(".source_image__title");
+        var digest = img.querySelector(".click_to_copy_text_element");
+        return {
+          name: title
+            ? clean(title.childNodes[0] && title.childNodes[0].textContent)
+            : "",
+          digest: clean(digest && digest.textContent),
+        };
+      });
+    },
+
+    getFindingsGrouped: function () {
+      requireReportPage();
+
+      return Array.from(
+        document.querySelectorAll("details.findings_section_details"),
+      )
+        .map(function (section) {
+          var summaryEl = section.querySelector("summary");
+          var summary = clean(
+            (summaryEl && summaryEl.textContent) || section.textContent,
+          );
+          var dateMatch = summary.match(
+            /^[A-Z][a-z]{2} \d{2} [A-Z][a-z]{2,3} \d{2}:\d{2}/,
+          );
+          var date = dateMatch ? dateMatch[0] : "";
+
+          var findings = Array.from(
+            section.querySelectorAll(
+              "a.w_fit.anchor_remove-style.w_full.justify_start",
+            ),
+          )
+            .map(function (anchor) {
+              var text = clean(anchor.textContent)
+                .replace(/Look into this finding$/, "")
+                .trim();
+              var match = text.match(/^(new|resolved|rare\??)\s*(.+)$/i);
+              if (!match) return null;
+              return {
+                status: match[1].replace(/\?$/, "").toLowerCase(),
+                property: match[2].trim(),
+              };
+            })
+            .filter(Boolean);
+
+          return { date: date, findings: findings };
+        })
+        .filter(function (group) {
+          return group.date && group.findings.length > 0;
+        });
+    },
+
+    getAllProperties: getAllProperties,
+    getExampleLogsUrl: getExampleLogsUrl,
+    getPropertyExamples: getPropertyExamples,
+  };
+
+  var logsApi = {
+    loadingFinished: function () {
+      var wrapper = document.querySelector(".sequence_printer_wrapper");
+      var filterInput = document.querySelector(".sequence_filter__input");
+      var searchInput = document.querySelector(".sequence_search__input");
+      var counter = document.querySelector(".sequence_toolbar__items-counter");
+      var counterText = clean(counter && counter.textContent);
+      var visibleEvents = Array.from(
+        document.querySelectorAll(".event"),
+      ).filter(isVisible).length;
+      var hasItemCount = /(\d[\d,]*)\s*items?\b/i.test(counterText);
+      var inSelectedLogView =
+        /[?&]get_logs=true\b/.test(window.location.search) ||
+        /selected event log/i.test(clean(document.body.textContent));
+
+      return !!(
+        inSelectedLogView &&
+        isVisible(wrapper) &&
+        isVisible(filterInput) &&
+        isVisible(searchInput) &&
+        isVisible(counter) &&
+        visibleEvents > 0 &&
+        hasItemCount &&
+        !/loading logs/i.test(counterText)
+      );
+    },
+
+    loadingStatus: function () {
+      var counterEl = document.querySelector(
+        ".sequence_toolbar__items-counter",
+      );
+      return {
+        url: window.location.href,
+        wrapperVisible: isVisible(
+          document.querySelector(".sequence_printer_wrapper"),
+        ),
+        filterVisible: isVisible(
+          document.querySelector(".sequence_filter__input"),
+        ),
+        searchVisible: isVisible(
+          document.querySelector(".sequence_search__input"),
+        ),
+        counterVisible: isVisible(counterEl),
+        visibleEvents: Array.from(document.querySelectorAll(".event")).filter(
+          isVisible,
+        ).length,
+        itemCounter: clean(counterEl && counterEl.textContent),
+      };
+    },
+
+    waitForReady: async function (options) {
+      requireLogsPage();
+      return waitForReady(
+        function () {
+          return logsApi.loadingFinished();
+        },
+        function () {
+          return logsApi.loadingStatus();
+        },
+        options,
+      );
+    },
+
+    getLogViewers: function () {
+      var wrappers = document.querySelectorAll(".sequence_printer_wrapper");
+      return Array.from(wrappers).map(function (wrapper, i) {
+        var text = wrapper.textContent || "";
+        var itemMatch = text.match(/(\d[\d,]*)\s*items?\b/i);
+        var itemCount = itemMatch
+          ? Number(itemMatch[1].replace(/,/g, ""))
+          : null;
+        var prev = wrapper.previousElementSibling;
+        var label = prev ? clean(prev.textContent) : null;
+        var rect = wrapper.getBoundingClientRect();
+        return {
+          index: i,
+          label: label,
+          itemCount: itemCount,
+          visible: rect.width > 0 && rect.height > 0,
+        };
+      });
+    },
+
+    prepareDownload: function (format, index) {
+      var fmt = (format || "txt").toLowerCase();
+      var downloadMap = {
+        txt: "events.log",
+        json: "events.json",
+        csv: "events.csv",
+      };
+      var filename = downloadMap[fmt];
+      if (!filename) {
+        abort("unsupported format: " + format + "; use txt, json, or csv");
+      }
+
+      var wrappers = document.querySelectorAll(".sequence_printer_wrapper");
+      if (wrappers.length === 0) {
+        abort("no log viewers found on this page");
+      }
+
+      var idx = index != null ? index : 0;
+      if (idx < 0 || idx >= wrappers.length) {
+        abort(
+          "log viewer index " +
+            idx +
+            " out of range; page has " +
+            wrappers.length +
+            " viewer(s)",
+        );
+      }
+
+      var wrapper = wrappers[idx];
+
+      var link = wrapper.querySelector(
+        'a.sequence_printer_menu_button[download="' + filename + '"]',
+      );
+      if (!link) {
+        abort("download link not found for format: " + fmt);
+      }
+
+      // Force the shadow-root menu visible so agent-browser can click the link.
+      var aMenu = link.closest("a-menu");
+      var shadowMenu =
+        aMenu && aMenu.shadowRoot && aMenu.shadowRoot.querySelector("menu");
+      if (!shadowMenu) {
+        abort("shadow menu not found for download link in viewer " + idx);
+      }
+      shadowMenu.style.display = "flex";
+      shadowMenu.style.position = "fixed";
+      shadowMenu.style.top = "0";
+      shadowMenu.style.left = "0";
+      shadowMenu.style.zIndex = "99999";
+
+      // Clear any previous marker, then tag this link for agent-browser.
+      wrappers.forEach(function (w) {
+        var prev = w.querySelector("[data-ab-dl]");
+        if (prev) prev.removeAttribute("data-ab-dl");
+      });
+      link.setAttribute("data-ab-dl", "active");
+
+      var selector = "a.sequence_printer_menu_button[data-ab-dl]";
+      return { format: fmt, filename: filename, selector: selector };
+    },
+  };
+
+  var runsApi = {
+    loadingFinished: function () {
+      var scroller = document.querySelector(".vscroll");
+      if (!scroller) return false;
+
+      var rows = Array.from(document.querySelectorAll("a-row"));
+      if (rows.length === 0) return false;
+
+      var hasRenderedCells = rows.some(function (row) {
+        return row.querySelector("a-cell, [cell-identifier]");
+      });
+      if (!hasRenderedCells) return false;
+
+      // Cells exist structurally but may not have hydrated with data yet.
+      // Require at least one row with a non-empty name or status cell.
+      var hasPopulatedRow = rows.some(function (row) {
+        var name = row.querySelector('[cell-identifier="name"]');
+        var status = row.querySelector('[cell-identifier="status"]');
+        return (
+          (name && clean(name.textContent) !== "") ||
+          (status && clean(status.textContent) !== "")
+        );
+      });
+      if (!hasPopulatedRow) return false;
+
+      var hasVisibleLoadingIndicator = Array.from(
+        scroller.querySelectorAll("*"),
+      ).some(function (el) {
+        return (
+          isVisible(el) && /^Loading(?:\.\.\.)?$/.test(clean(el.textContent))
+        );
+      });
+
+      return !hasVisibleLoadingIndicator;
+    },
+
+    loadingStatus: function () {
+      var scroller = document.querySelector(".vscroll");
+      var rows = Array.from(document.querySelectorAll("a-row"));
+
+      return {
+        url: window.location.href,
+        hasScroller: !!scroller,
+        rows: rows.length,
+        hasRenderedCells: rows.some(function (row) {
+          return row.querySelector("a-cell, [cell-identifier]");
+        }),
+      };
+    },
+
+    waitForReady: async function (options) {
+      return waitForReady(
+        function () {
+          return runsApi.loadingFinished();
+        },
+        function () {
+          return runsApi.loadingStatus();
+        },
+        options,
+      );
+    },
+
+    /**
+     * getRecentRuns(options?)
+     *   options.status - optional status filter: "Starting", "In progress",
+     *                    "Completed", "Cancelled", or "Incomplete"
+     */
+    getRecentRuns: async function (options) {
+      requireRunsPage();
+      options = options || {};
+
+      var statusFilter = options.status || null;
+      var statusOption = null;
+
+      // Apply status filter if requested.
+      if (statusFilter) {
+        var allOptions = Array.from(
+          document.querySelectorAll('a-menu-item[role="option"]'),
+        );
+        statusOption = allOptions.find(function (o) {
+          return clean(o.textContent) === statusFilter;
+        });
+        if (!statusOption)
+          abort(
+            "status filter option '" +
+              statusFilter +
+              "' not found. Valid options: " +
+              allOptions
+                .map(function (o) {
+                  return clean(o.textContent);
+                })
+                .join(", "),
+          );
+        statusOption.click();
+        await wait(300);
+
+        // Wait for the filtered list to settle.
+        await runsApi.waitForReady({ timeoutMs: 10000 });
+      }
+
+      var scroller = document.querySelector(".vscroll");
+      if (!scroller) abort("runs scroller not found");
+
+      var runs = new Map();
+      var previousScrollTop = -1;
+      var stablePasses = 0;
+
+      // The runs table is virtualized, so walk the scroller and merge rendered rows.
+      for (var i = 0; i < 200; i++) {
+        Array.from(document.querySelectorAll("a-row")).forEach(function (row) {
+          var run = parseRunRow(row);
+          var key = runKey(run);
+          if (key) runs.set(key, run);
+        });
+
+        var maxScrollTop = Math.max(
+          0,
+          scroller.scrollHeight - scroller.clientHeight,
+        );
+        var nextScrollTop = Math.min(
+          maxScrollTop,
+          scroller.scrollTop +
+            Math.max(200, Math.floor(scroller.clientHeight * 0.8)),
+        );
+
+        if (
+          nextScrollTop === scroller.scrollTop ||
+          nextScrollTop === previousScrollTop
+        ) {
+          stablePasses += 1;
+          if (stablePasses >= 2) break;
+        } else {
+          stablePasses = 0;
+        }
+
+        previousScrollTop = scroller.scrollTop;
+        scroller.scrollTop = nextScrollTop;
+        scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
+        await wait(100);
+      }
+
+      // Clear the status filter to leave the page in a clean state.
+      if (statusOption) {
+        statusOption.click();
+        await wait(100);
+      }
+
+      return {
+        count: runs.size,
+        runs: Array.from(runs.values()),
+      };
+    },
+  };
+
+  var api = {
+    __version: VERSION,
+    report: reportApi,
+    logs: logsApi,
+    runs: runsApi,
+    info: function () {
+      return {
+        version: VERSION,
+        description: "Antithesis agent-browser runtime",
+        namespaces: {
+          report: Object.keys(reportApi).sort(),
+          logs: Object.keys(logsApi).sort(),
+          runs: Object.keys(runsApi).sort(),
+        },
+      };
+    },
+  };
+
+  window.__antithesisAgentBrowser = api;
+  return api.info();
+})();
diff --git a/.claude/skills/antithesis-agent-browser/assets/download-logs-from-url.sh b/.claude/skills/antithesis-agent-browser/assets/download-logs-from-url.sh
new file mode 100755
index 0000000..bc0a3e8
--- /dev/null
+++ b/.claude/skills/antithesis-agent-browser/assets/download-logs-from-url.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+#
+# download-logs-from-url.sh — Download an Antithesis log file via
+# agent-browser, given a logs page URL.
+#
+# Usage:
+#   download-logs-from-url.sh --url  --output  [--format json|txt|csv]
+#
+# Creates a fresh browser session with shared antithesis auth, navigates to
+# the log URL, and downloads the log file in the requested format. The output
+# is written verbatim — no post-processing. If the caller wants vtime/fault
+# annotation on a JSON download, pipe the result through
+# antithesis-debug/assets/process-logs.py separately.
+#
+# Exit codes:
+#   0  success
+#   2  timeout (page or logs failed to load)
+#   3  download error
+#   4  usage error
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+RUNTIME_JS="${SCRIPT_DIR}/antithesis-agent-browser.js"
+
+URL=""
+OUTPUT=""
+FORMAT="json"
+
+usage() {
+  echo "Usage: download-logs-from-url.sh --url  --output  [--format json|txt|csv]" >&2
+  exit 4
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --url)      URL="$2"; shift 2 ;;
+    --output)   OUTPUT="$2"; shift 2 ;;
+    --format)   FORMAT="$2"; shift 2 ;;
+    -h|--help)  usage ;;
+    *)          echo "Unknown option: $1" >&2; usage ;;
+  esac
+done
+
+[[ -z "$URL" ]] && { echo "Error: --url is required" >&2; usage; }
+[[ -z "$OUTPUT" ]] && { echo "Error: --output is required" >&2; usage; }
+
+case "$FORMAT" in
+  json|txt|csv) ;;
+  *) echo "Error: --format must be json, txt, or csv" >&2; usage ;;
+esac
+
+# Ensure output directory exists.
+mkdir -p "$(dirname "$OUTPUT")"
+
+# Generate a unique session for this download.
+SESSION="download-logs-$(date +%s)-$$"
+TMPFILE=$(mktemp)
+
+cleanup() {
+  agent-browser --session "$SESSION" close >/dev/null 2>&1 || true
+  rm -f "$TMPFILE"
+}
+trap cleanup EXIT
+
+# Step 1: Open the log URL with shared auth.
+echo "Opening log URL..." >&2
+if ! agent-browser --session "$SESSION" --session-name antithesis open "$URL" >/dev/null 2>&1; then
+  echo "Error: failed to open URL" >&2
+  exit 3
+fi
+
+# Step 2: Wait for the page to settle, then verify we landed on the logs page.
+agent-browser --session "$SESSION" wait --load networkidle >/dev/null 2>&1 || true
+CURRENT_URL=$(agent-browser --session "$SESSION" get url 2>/dev/null || echo "unknown")
+if [[ "$CURRENT_URL" != */search* ]]; then
+  echo "Error: did not land on logs page (at: $CURRENT_URL)" >&2
+  exit 2
+fi
+
+# Step 3: Inject runtime.
+echo "Injecting runtime..." >&2
+if ! cat "$RUNTIME_JS" | agent-browser --session "$SESSION" eval --stdin >/dev/null 2>&1; then
+  echo "Error: failed to inject agent-browser runtime" >&2
+  exit 3
+fi
+
+# Step 4: Wait for log viewer to be ready.
+echo "Waiting for logs to load..." >&2
+if ! agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.logs.waitForReady()" >/dev/null 2>&1; then
+  echo "Error: log viewer did not become ready" >&2
+  exit 2
+fi
+
+# Step 5: Prepare the download link.
+echo "Preparing download ($FORMAT)..." >&2
+if ! agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.logs.prepareDownload('${FORMAT}', 0)" >/dev/null 2>&1; then
+  echo "Error: prepareDownload failed" >&2
+  exit 3
+fi
+
+# Step 6: Download to a temp file to avoid partial/corrupt output.
+echo "Downloading..." >&2
+if ! agent-browser --session "$SESSION" download \
+  'a.sequence_printer_menu_button[data-ab-dl]' "$TMPFILE" >/dev/null 2>&1; then
+  echo "Error: download failed" >&2
+  exit 3
+fi
+
+if [[ ! -s "$TMPFILE" ]]; then
+  echo "Error: downloaded file is empty" >&2
+  exit 3
+fi
+
+# Step 7: Move to final destination.
+mv "$TMPFILE" "$OUTPUT"
+
+echo "Done: $OUTPUT" >&2
diff --git a/.claude/skills/antithesis-agent-browser/references/error-reports.md b/.claude/skills/antithesis-agent-browser/references/error-reports.md
new file mode 100644
index 0000000..822bb08
--- /dev/null
+++ b/.claude/skills/antithesis-agent-browser/references/error-reports.md
@@ -0,0 +1,103 @@
+# Error reports
+
+Not every report loads normally. Antithesis may show an **error report**
+instead of the usual property/findings view. The runtime detects
+two kinds of error:
+
+| Error type        | `error.type`    | What it looks like                                                                                                                                                                                                             |
+| ----------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Setup failure** | `setup_error`   | The report replaces the normal sections with a single "Error" card describing a container setup failure (e.g. a container died, the setup-complete event was never emitted). Properties, Findings, and Utilization are absent. |
+| **Runtime error** | `runtime_error` | A red/orange banner appears at the top of the page (class `GeneralErrorNew`). The normal sections may partially render but one or more (typically Findings) will be stuck on "Loading..." forever.                             |
+
+## Detection
+
+`waitForReady()` short-circuits when an error is detected — it will **not**
+wait 60 seconds for sections that will never load. The returned result object
+will contain an `error` field:
+
+```json
+{
+  "attempts": 1,
+  "waitedMs": 42,
+  "error": {
+    "type": "setup_error",
+    "summary": "Container setup failure",
+    "details": "Setup validation failures:\n• Container floci died during environment setup with exit code 1..."
+  }
+}
+```
+
+**After every report `waitForReady()` call, check `result.error`.** If it is
+present, the report is an error report and you should change your workflow:
+
+1. **Read the error details.** `result.error.details` contains the error
+   message. For setup errors this includes the validation failure and
+   troubleshooting steps. For runtime errors it contains the backend query
+   failure message.
+2. **Read any inline log viewers.** Sometimes errors include details in log
+   viewers. Read those before continuing.
+3. **Report the error to the user.** Explain which error type was found,
+   quote the details, and suggest next steps (fix the setup, contact
+   Antithesis, or re-run).
+
+You can also check for errors at any time with:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.report.getError()"
+```
+
+This returns the error object (same shape as `result.error`) or `null` if the
+report is healthy.
+
+## What to skip on error reports
+
+- **Setup errors (`setup_error`)**: Do **not** call property, findings methods —
+  those sections do not exist. Focus on metadata, environment images, the error
+  details, and any inline error logs.
+- **Runtime errors (`runtime_error`)**: Runtime errors means something broke on
+  Antithesis's side while building the report. You may encounter errors while
+  extracting any data or certain parts of the report may fail to load. Proceed
+  cautiously, but still try to extract what you can for the user.
+
+## Downloading inline error logs
+
+Error reports may expose inline log panes. To download them, first discover
+the available log viewers, then use `prepareDownload` with the correct index:
+
+```bash
+# List log panes on the page (works on any page with log viewers).
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.logs.getLogViewers()"
+```
+
+Each entry has `index`, `label`, `itemCount`, and `visible`. Use the `index`
+to prepare and download:
+
+```bash
+# Prepare the download link for pane 0 as JSON.
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.logs.prepareDownload('json', 0)"
+
+# Download the file.
+agent-browser --session "$SESSION" download \
+  'a.sequence_printer_menu_button[data-ab-dl]' /tmp/error-logs.json
+```
+
+Downloaded logs are minified single-line JSON — always use `jq` to inspect them, never `cat` or `wc -l`. `jq length ` is a good first command to start with.
+
+For multiple log panes, repeat with each index.
+
+## Known errors cheatsheet
+
+### `Error in reducer serialize`
+
+- **Type:** `runtime_error`
+- **Cause:** Antithesis backend failed to serialize data used in the generation of the report. Some of the sections in the report may not load.
+- **What to do:** Triage what data you can, report the error to the user if it prevents you from making progress. This is an Antithesis error, not a SUT bug.
+
+### `Setup validation failures`
+
+- **Type:** `setup_error`
+- **Cause:** One or more setup checks failed (e.g. missing setup-complete event, container not joining network).
+- **What to do:** Read `error.details` for the specific validation failures. Check any inline log views for detailed logs. Verify the setup-complete event is being emitted properly. Remember that Antithesis does not run any test-templates before receiving setup-complete.
diff --git a/.claude/skills/antithesis-agent-browser/references/properties-from-ui.md b/.claude/skills/antithesis-agent-browser/references/properties-from-ui.md
new file mode 100644
index 0000000..b3262da
--- /dev/null
+++ b/.claude/skills/antithesis-agent-browser/references/properties-from-ui.md
@@ -0,0 +1,119 @@
+# Property queries
+
+`report` refers to `window.__antithesisAgentBrowser.report` in this file.
+
+## Getting all properties
+
+`report.getAllProperties()` returns every property in a single JSON object.
+All properties are already expanded by `waitForReady()`, so this is a
+synchronous read of the current DOM state — no tab switching or expansion
+occurs.
+
+Each entry in the `properties` array:
+
+```json
+{
+  "group": ["SDK: Go"],
+  "name": "example property",
+  "status": "failed",
+  "passingCount": "3,529",
+  "failingCount": "10,409"
+}
+```
+
+`passingCount` and `failingCount` are comma-formatted count strings representing the total across all execution histories in the run (not just the 3-4 example rows shown in the UI).
+
+### Filtering properties with jq
+
+Use `jq` to filter the output of `getAllProperties()` rather than calling
+separate status-specific methods:
+
+```bash
+# Failed properties only
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.report.getAllProperties()" \
+  | jq '.properties | map(select(.status == "failed"))'
+
+# Passed properties only
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.report.getAllProperties()" \
+  | jq '.properties | map(select(.status == "passed"))'
+
+# Unfound properties only
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.report.getAllProperties()" \
+  | jq '.properties | map(select(.status == "unfound"))'
+
+# Properties in a specific group
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.report.getAllProperties()" \
+  | jq '.properties | map(select(.group | any(test("SDK: Go"))))'
+```
+
+### Using pass/fail ratios for triage prioritization
+
+- **All failing (0 passing)** — Likely a setup or workload bug. The property is being violated in every execution history.
+- **Mostly failing with rare passes** — Could be a workload issue that only succeeds under specific conditions, or a real bug that's hard to avoid.
+- **Mostly passing with rare failures** — Strong candidate for a real SUT bug. Pay attention to rare event orderings or fault patterns in the logs.
+- **Roughly even split** — The property may be sensitive to configuration or timing. Check whether passing vs failing correlates with fault intensity.
+
+## Assertion types and what they mean for triage
+
+Each property is backed by an assertion of a specific type. The type determines what a failure actually tells you:
+
+- **`Always`**: Must be true every evaluation. Fails if the condition is false at least once.
+- **`AlwaysOrUnreachable`**: Either never reached, or true every time reached. Fails if reached at least once AND false at least once. A rare or optional path was exercised and the invariant didn't hold. The path being reached is itself informative.
+- **`Sometimes`**: Must be true at least once across the entire run. Fails if the condition is never true.
+- **`Reachable`**: The assertion point must be reached at least once. Fails if never reached. Could be a test coverage gap, a workload that never triggers the state, or a SUT bug that prevents the path.
+- **`Unreachable`**: The assertion point must never be reached. Fails if reached at least once. A forbidden or impossible path was entered.
+
+`Always` and `Sometimes` assertions imply `Reachable`. If any `Reachable` assertion fails but has no examples, this means that it was never reached. This might simply be due to the test not running long enough, or it may be that the workload is not triggering the state. It may also mean that a SUT bug is preventing the assertion from being reached, although ideally you can discern that via another property that catches the bug.
+
+Numeric/boolean variants (e.g., `AlwaysGreaterThan`, `SometimesAll`) follow the same pass/fail semantics as their base type but attach the compared operands to assertion details automatically.
+
+## Property examples
+
+`report.getPropertyExamples()` returns all properties that have example tables,
+along with their example rows. All example tables are already expanded by
+`waitForReady()`, so this is a synchronous read of the DOM. Use jq to filter
+by status.
+
+Returns each property with `group`, `name`, `status`, and `examples` array
+containing `{ index: 0, status: "failing", time: "85.75s" }` entries.
+
+```bash
+# Failed property examples only
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.report.getPropertyExamples()" \
+  | jq '.properties | map(select(.status == "failed"))'
+```
+
+Each property may expose multiple example rows (typically 3-4), mixing failing
+and passing examples. When triaging, start with the **first failing example**
+(usually index 0) by default. Cross-referencing a passing example can help
+narrow down root cause by showing what's different in a healthy execution.
+
+## Example log URLs
+
+Eval `report.getExampleLogsUrl(propertyName, exampleIndex)` to retrieve the log URL for a specific example. Returns `{ propertyName, exampleIndex, logsUrl }`. Throws if the property is not found or the example index is out of range.
+
+> **Treat log URLs as opaque.** Never attempt to decode, parse, or extract data
+> from URL query parameters.
+
+## Download logs from a log URL
+
+Use `assets/download-logs-from-url.sh` to fetch a log file from a given log URL:
+
+```bash
+bash assets/download-logs-from-url.sh \
+  --url "$LOGS_URL" \
+  --output /tmp/logs/property-name.json
+```
+
+Always download logs to a unique path unless you have explicit instructions otherwise. Other agents may be concurrently downloading logs.
+
+If you do not have access to `bash` on this machine, read the download script and perform the steps manually.
+
+The script creates its own browser session using shared `antithesis` auth, navigates to the URL, waits for the page to load, and downloads the file verbatim (no post-processing). The default format is JSON. Use `--format txt` or `--format csv` when asked, but prefer JSON whenever possible.
+
+To annotate a downloaded JSON log with `vtime_seconds` and `active_faults`, pipe it through `antithesis-debug/assets/process-logs.py`.
diff --git a/.claude/skills/antithesis-agent-browser/references/run-discovery-ui.md b/.claude/skills/antithesis-agent-browser/references/run-discovery-ui.md
new file mode 100644
index 0000000..be21502
--- /dev/null
+++ b/.claude/skills/antithesis-agent-browser/references/run-discovery-ui.md
@@ -0,0 +1,42 @@
+# Run Discovery
+
+When `snouty runs list` is unavailable, you can search for recent runs at `https://$TENANT.antithesis.com/runs`.
+
+To filter runs by text, append the `primary-filter` query parameter when navigating to the runs page. Make sure to URL escape the filter value.
+
+```
+https://$TENANT.antithesis.com/runs?primary-filter=FILTER_TEXT
+```
+
+This pre-fills the "Filter by text" input and restricts the visible runs to those matching the filter. Use this when looking for runs by name, repository, or other text. You can also modify the `Filter by text` input to change the filter while on the runs page. The input selector is:
+
+```
+input.input_input[placeholder="Filter by text"]
+```
+
+The runs page is a virtualized grid rendered with `a-row` / `a-cell`, not a
+plain HTML ``. Rows are loaded in a `.vscroll` container, so a DOM query only sees the currently rendered rows unless you scroll.
+
+You can load the entire runs table using `getRecentRuns`:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.runs.getRecentRuns()"
+```
+
+To filter by status, pass a `status` option. Valid values: `Starting`,
+`In progress`, `Completed`, `Cancelled`, `Incomplete`.
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisAgentBrowser.runs.getRecentRuns({status: 'Incomplete'})"
+```
+
+The filter is applied before scrolling and automatically cleared afterward,
+so the page is left in a clean state.
+
+Notes:
+
+- `triageUrl` is `null` for runs that are still in progress and do not yet have a report.
+- `findings` is keyed by labels such as `new`, `ongoing`, `resolved`, and `rare`.
+- `utilization` is keyed by labels such as `Test hours`, `Setup`, and `Explore`.
diff --git a/.claude/skills/antithesis-agent-browser/references/setup-auth.md b/.claude/skills/antithesis-agent-browser/references/setup-auth.md
new file mode 100644
index 0000000..521e798
--- /dev/null
+++ b/.claude/skills/antithesis-agent-browser/references/setup-auth.md
@@ -0,0 +1,44 @@
+# Authentication
+
+If the shared `--session-name antithesis` state does not leave you
+authenticated, run the interactive login flow below. It opens a real, visible
+browser window for sign-in and 2FA; `agent-browser` saves the session
+automatically because you pass `--session-name`.
+
+## Pre-flight: confirm before opening a window
+
+Never open the login window blind — an unannounced window reads as a security
+scare, and a user who walked away (or closes it in annoyance) leaves you
+waiting on a sign-in that never completes.
+
+On Linux, check `DISPLAY` and `WAYLAND_DISPLAY`: if neither is set, this is
+very likely a remote/headless machine where no window can be shown. On macOS
+or elsewhere you cannot tell from the environment.
+
+In every case, before opening anything, tell the user a window is about to
+appear for sign-in and ask them to confirm they are present and ready — and to
+report if no window shows up, so you stop instead of looping. Only open it once
+they confirm. If they cannot see a browser or are not available, STOP: do not
+open a window or retry; suggest running you on a machine with a display.
+
+## Interactive login
+
+Open the headed login window:
+
+```sh
+agent-browser --session "$SESSION" close
+agent-browser --session "$SESSION" --session-name antithesis --headed open "https://antithesis.com/login/?redirect=home"
+```
+
+Once the user confirms they have completed authentication, close the headed
+browser and reopen the same session headless with the same `--session-name
+antithesis` before continuing.
+
+```sh
+agent-browser --session "$SESSION" close
+agent-browser --session "$SESSION" --session-name antithesis open "https://$TENANT.antithesis.com"
+agent-browser --session "$SESSION" get url
+```
+
+If this still redirects to a login page, the interactive auth did not
+complete. Do NOT loop on the auth check — surface the failure to the user.
diff --git a/.claude/skills/antithesis-debug/SKILL.md b/.claude/skills/antithesis-debug/SKILL.md
new file mode 100644
index 0000000..1c1ee5d
--- /dev/null
+++ b/.claude/skills/antithesis-debug/SKILL.md
@@ -0,0 +1,303 @@
+---
+name: antithesis-debug
+description: >
+  Interactively debug an Antithesis test run in the multiverse debugger
+  (MVD): launch a session from a run, open a debugging-session URL, and
+  inspect container filesystem and runtime state from inside the run.
+compatibility: Requires agent-browser v0.23.4+ (https://github.com/vercel-labs/agent-browser).
+metadata:
+  version: "2026-07-14 1f59c97"
+---
+
+# Antithesis Multiverse Debugger
+
+**Skill version:** `2026-07-14 1f59c97`
+
+Use the `agent-browser` skill to interact with the Antithesis multiverse
+debugger.
+
+Every debugging session should use:
+
+- a fresh, unique `SESSION` value such as `antithesis-debug-$(date +%s)-$$`
+
+Use `--session-name antithesis` so `agent-browser` manages shared
+authentication state automatically, while `--session "$SESSION"` keeps each
+debugging run isolated from other concurrent agents. Close the unique live
+session when debugging is complete.
+
+## When to use this skill
+
+Use this when:
+
+- the user gives an Antithesis debugging-session URL
+- the user makes a request to inspect container filesystem, runtime state, or events inside Antithesis
+- the triage skill has launched an MVD session when investigating the results of a run
+
+For auth and report navigation, use the `antithesis-agent-browser` skill. It
+owns the `agent-browser` session and authentication flow. This skill handles
+the debugger itself.
+
+## Prerequisites
+
+- DO NOT PROCEED if `agent-browser` is not installed. See `https://raw.githubusercontent.com/vercel-labs/agent-browser/refs/heads/main/README.md` for installation options.
+- DO NOT PROCEED if `agent-browser` is older than version `v0.23.4`. You can upgrade with `agent-browser upgrade`.
+
+## Gathering user input
+
+Before starting, collect the following from the user:
+
+1. **Debugger URL** (required) — A debugging-session URL like `https://TENANT.antithesis.com/debugging-session/...`.
+2. **What to investigate** — Are we checking filesystem contents? Runtime state? Specific artifacts?
+3. **Container name** (if known) — The name of the container to target. If not provided, the log view or container dropdown will show available containers.
+
+## Simplified vs. advanced mode
+
+The debugger has two modes. **Prefer simplified mode** — it is sufficient for
+most tasks.
+
+| Mode           | Best for                                                                                         | How it works                                                            |
+| -------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
+| **Simplified** | Running shell commands, reading files, listing directories, extracting artifacts                 | Click log lines to set moment/container, type bash commands, press Send |
+| **Advanced**   | Programmatic inspection, branching, event sets, custom JavaScript, multi-step notebook workflows | Monaco editor with JavaScript cells, action authorization, runtime API  |
+
+### Detecting which mode is active
+
+Different tenants may open the debugger in either mode by default. After
+injecting the runtime (see `references/setup-session.md`), check which mode is
+active:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.getMode()"
+```
+
+Returns `"simplified"` or `"advanced"`. To switch:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.switchMode("simplified")'
+```
+
+### When to escalate to advanced mode
+
+Switch to advanced mode only when you need:
+
+- **A target container that had crashed (or otherwise wasn't running) at the MVD recreate moment.** The simplified container dropdown only lists containers running at that point. To inspect a container while it was alive, you must time-travel back via `moment.rewind_to(...)` and target it by name via `bash...run({container: ""})`. This is the one container-targeting case where advanced is strictly required.
+- Branching (`moment.branch()`) and advancing time (`branch.wait`, `branch.wait_until`)
+- Exploring multiple random histories from the same moment (`branch.send_input`)
+- Event set queries (`environment.events.up_to(moment)`, `.contains({source})`)
+- Fault injector state inspection or control (`environment.fault_injector.pause/unpause`)
+- Background processes and process-exit awaiting (`run_in_background`, `p.exits`)
+- Complex multi-cell notebook workflows with grouped authorization
+- The full JavaScript notebook API
+- As an escape hatch when something in simplified mode isn't working
+
+To switch programmatically, call `window.__antithesisDebug.switchMode("advanced")`.
+This is what `getMode()` reports. **Note:** there is a separate "Simple
+mode" / "Advanced mode" tab strip in the right pane (`.display_area__tabs`)
+that `switchMode()` does NOT click; the two can disagree. The right-pane
+tab strip controls the help / pop-out panel content. To read the on-page
+advanced-mode help (a canonical ~5KB reference for the notebook API), click
+the "Advanced mode" tab in that strip and read
+`document.querySelector(".display_area__layout").innerText`. See
+`references/advanced-debugger.md`.
+
+## Reference files
+
+Each reference file covers a specific interaction mode or task. Read the
+relevant file before performing that task.
+
+### Simplified mode (default — start here)
+
+| Page                                | When to read                                                        |
+| ----------------------------------- | ------------------------------------------------------------------- |
+| `references/setup-session.md`       | Always — read first to set up the browser session                   |
+| `references/simplified-debugger.md` | Running commands, extracting files, reading logs in simplified mode |
+
+### Advanced mode (using a notebook)
+
+| Page                                | When to read                                                                              |
+| ----------------------------------- | ----------------------------------------------------------------------------------------- |
+| `references/setup-session.md`       | Always — read first to set up the browser session                                          |
+| `references/advanced-debugger.md`   | **Read this BEFORE other advanced refs** — mental model, mode-switching, authorization, branches, time advancement, fault injector, host commands, common errors |
+| `references/notebook.md`            | Reading or writing notebook source, injecting cells                                       |
+| `references/actions.md`             | Authorizing shell actions, reading action output                                          |
+| `references/common-inspections.md`  | Ready-to-use debug cell snippets for common tasks                                         |
+
+## Recommended workflows
+
+### Simplified: Run a command in a container
+
+1. Read `references/setup-session.md` — open the debugger URL
+2. Read `references/simplified-debugger.md` — click a log line to set the
+   moment and container, enter a bash command, press Send, read the output
+3. Report findings with concrete evidence
+
+### Simplified: Extract a file
+
+1. Read `references/setup-session.md` — open the debugger URL
+2. Read `references/simplified-debugger.md` — click a log line, toggle
+   "Extract file", enter the file path, press Send
+3. Read the download link from the output
+
+### Advanced: Programmatic investigation
+
+1. Read `references/setup-session.md` — open the debugger URL and inject runtime
+2. Switch to advanced mode: `window.__antithesisDebug.switchMode("advanced")`,
+   then `notebook.waitForReady()`
+3. Read `references/advanced-debugger.md` — internalize moments, branches,
+   reactive-vs-effectful, action grouping, and common errors
+4. Read `references/notebook.md` — mechanics of writing cells
+5. Read `references/common-inspections.md` — ready-to-use snippets
+6. Read `references/actions.md` — authorizing and reading action results
+7. Report findings with evidence chain
+
+### Download the events log for offline analysis
+
+1. Read `references/setup-session.md` — launch / open the debugger URL
+2. Read `references/download-log.md` — run `assets/download-mvd-log.sh`
+   to capture and (for JSON) annotate the events log. The annotation step
+   uses this skill's own `assets/process-logs.py`.
+3. Analyze the local file with `jq` (event shape matches the triage
+   skill's logs reference)
+
+## Runtime injection
+
+The JS runtime is required for **both** simplified and advanced modes. It
+provides the `window.__antithesisDebug` API with three namespaces:
+`simplified`, `notebook`, and `actions`.
+
+Use the browser-side runtime file:
+
+- `assets/antithesis-debug.js`
+
+Inject it into the current page with:
+
+```bash
+cat assets/antithesis-debug.js \
+  | agent-browser --session "$SESSION" eval --stdin
+```
+
+Injecting the file registers methods on `window.__antithesisDebug`. Call those
+methods with `agent-browser eval`.
+
+Method call examples:
+
+```bash
+# Simplified mode
+agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.simplified.runCommand("ls -la /")'
+
+# Advanced mode
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.getSource()"
+```
+
+`agent-browser eval` awaits Promises automatically, so async and sync methods
+use the same call pattern.
+
+If `window.__antithesisDebug` is missing, inject `assets/antithesis-debug.js` and retry the method call.
+
+Do not run method calls in parallel with `agent-browser open`, navigation, or
+any other command that can replace the page. Wait until the target page is
+settled before starting `eval` calls. Run queries sequentially; mutations will
+interfere with each other if launched in parallel.
+
+After every `open` call or any interaction that may navigate or replace the
+page, first confirm the browser has landed on the debugger page, then inject
+`assets/antithesis-debug.js` and call the appropriate `waitForReady()` before
+running other methods.
+
+## Page loading checks
+
+After navigation, inject the runtime, then call the appropriate `waitForReady`:
+
+```bash
+# Simplified mode
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.waitForReady()"
+
+# Advanced mode
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.waitForReady()"
+```
+
+Both poll for up to 60 seconds and return `{ ok, ready, attempts, waitedMs }`.
+On timeout, the result also includes `details`.
+
+One-shot probes:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.loadingFinished()"
+
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.loadingFinished()"
+```
+
+Diagnostics:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.loadingStatus()"
+
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.loadingStatus()"
+```
+
+## General guidance
+
+- **Start in simplified mode.** The simplified debugger handles most debugging
+  tasks. Only switch to advanced mode when you specifically need the notebook
+  API.
+- **Defer to antithesis-agent-browser for auth.** If the debugger URL
+  requires authentication, use the `antithesis-agent-browser` skill's
+  `references/setup-auth.md` for the interactive login flow. Use the same
+  `--session-name antithesis` so auth state is shared.
+- **Use disposable sessions.** Generate a unique `SESSION` for each debugging
+  run, pair it with the shared `--session-name antithesis`, and
+  `agent-browser --session "$SESSION" close` when you finish or abort.
+- **Run commands sequentially.** In both modes, wait for each command to
+  complete before sending the next one.
+- **Do not fabricate container names.** Use the container dropdown (simplified)
+  or `environment.containers.list({moment})` (advanced) to determine valid
+  container names.
+- **Present results clearly.** When reporting filesystem contents, include the
+  full path and listing. When reporting artifact searches, include what was
+  found and what was not.
+
+### Advanced mode only
+
+- **Inject the runtime after navigation.** After every `open` call or page
+  reload, inject `assets/antithesis-debug.js` and call
+  `notebook.waitForReady()` before the next method call.
+- **Retry missing-runtime errors by reinjecting.** If a command fails because
+  `window.__antithesisDebug` is undefined or missing, inject the runtime and
+  rerun the same method.
+- **Group effects with `action()` + `required_by`.** Don't authorize cells
+  one at a time when a single click can fire a chain — see
+  `references/advanced-debugger.md` for the `tethered_authorization`
+  pattern.
+- **Don't edit a command that has already been authorized.** Make a new
+  branch (and new action) for the new attempt. Editing an authorized cell
+  re-runs it without re-authorization (a known bug).
+- **A nonzero exit code terminates the branch.** Subsequent commands on
+  the same branch will fail with `CAMPAIGN SAW TERMINAL EVENT`. Fork a
+  fresh branch from the same or an earlier moment.
+- **Trust `cell.text`/`innerText` over `actionCompleted`.** The runtime's
+  completion-detection misses real DONE states; read the cell text directly.
+
+## Self-Review
+
+Before declaring this skill complete, review your work against the criteria
+below. This skill's output is conversational (summaries, evidence, analysis),
+so the review should happen in your current context. Re-read the guidance in
+this file, then systematically check each item below against the answers and
+analysis you produced.
+
+Review criteria:
+
+- Every filesystem listing or artifact search was extracted from actual debugger output, not inferred or assumed
+- The evidence chain is clear: which commands were run, what they returned, and what conclusions follow
+- The summary distinguishes between what the debugger shows and what you interpret or recommend
+- The browser session was closed at the end of the debugging run
diff --git a/.claude/skills/antithesis-debug/assets/antithesis-debug.js b/.claude/skills/antithesis-debug/assets/antithesis-debug.js
new file mode 100644
index 0000000..5011ef6
--- /dev/null
+++ b/.claude/skills/antithesis-debug/assets/antithesis-debug.js
@@ -0,0 +1,896 @@
+(function () {
+  var VERSION = "2.0.0";
+
+  // ---------------------------------------------------------------------------
+  // Shared utilities
+  // ---------------------------------------------------------------------------
+
+  function clean(text) {
+    return (text || "").replace(/\s+/g, " ").trim();
+  }
+
+  function wait(ms) {
+    return new Promise(function (resolve) {
+      setTimeout(resolve, ms);
+    });
+  }
+
+  function isVisible(el) {
+    if (!el || typeof el.getBoundingClientRect !== "function") return false;
+
+    var rect = el.getBoundingClientRect();
+    if (rect.width <= 0 || rect.height <= 0) return false;
+
+    var style = window.getComputedStyle(el);
+    return style.display !== "none" && style.visibility !== "hidden";
+  }
+
+  function click(el) {
+    if (!el) return false;
+
+    ["pointerdown", "mousedown", "mouseup", "click"].forEach(function (type) {
+      el.dispatchEvent(
+        new MouseEvent(type, {
+          bubbles: true,
+          cancelable: true,
+          composed: true,
+          view: window,
+        }),
+      );
+    });
+
+    return true;
+  }
+
+  function setNativeValue(el, value) {
+    var proto = el instanceof HTMLTextAreaElement
+      ? HTMLTextAreaElement.prototype
+      : HTMLInputElement.prototype;
+    var setter = Object.getOwnPropertyDescriptor(proto, "value").set;
+    setter.call(el, value);
+    el.dispatchEvent(new Event("input", { bubbles: true }));
+  }
+
+  function waitForReady(checkFn, detailsFn, options) {
+    var timeoutMs =
+      options && typeof options.timeoutMs === "number" ? options.timeoutMs : 60000;
+    var intervalMs =
+      options && typeof options.intervalMs === "number" ? options.intervalMs : 1000;
+    var startedAt = Date.now();
+    var deadline = startedAt + timeoutMs;
+    var attempts = 0;
+
+    return (async function () {
+      while (Date.now() <= deadline) {
+        attempts += 1;
+
+        if (checkFn()) {
+          return {
+            ok: true,
+            ready: true,
+            attempts: attempts,
+            waitedMs: Date.now() - startedAt,
+          };
+        }
+
+        if (Date.now() + intervalMs > deadline) break;
+        await wait(intervalMs);
+      }
+
+      return {
+        ok: false,
+        ready: false,
+        attempts: attempts,
+        waitedMs: Date.now() - startedAt,
+        details: detailsFn ? detailsFn() : null,
+      };
+    })();
+  }
+
+  // ---------------------------------------------------------------------------
+  // Mode detection — works regardless of which mode is active
+  // ---------------------------------------------------------------------------
+
+  function detectMode() {
+    // The Monaco editor is visible with substantial dimensions only in
+    // advanced mode.  In simplified mode it is either absent or hidden.
+    var editors = document.querySelectorAll(".monaco-editor");
+    for (var i = 0; i < editors.length; i++) {
+      var rect = editors[i].getBoundingClientRect();
+      if (rect.width > 200 && rect.height > 200) return "advanced";
+    }
+    // If a simplified-mode container is present, we are in simplified mode.
+    if (document.querySelector(".simple_multiverse_debugging_container")) {
+      return "simplified";
+    }
+    return null;
+  }
+
+  function switchMode(target) {
+    var current = detectMode();
+    if (current === target) return { ok: true, mode: target, switched: false };
+
+    // Open the three-dot menu first — the mode buttons are hidden inside it.
+    var dotsBtn = document.querySelector(
+      '.ceres_timeline_action_button_wrapper a-button[icon="dots-vertical"]',
+    );
+    if (dotsBtn) click(dotsBtn);
+
+    var cls =
+      target === "simplified"
+        ? ".ceres_presentation_mode_simple_button"
+        : ".ceres_presentation_mode_advanced_button";
+
+    // The menu may take a frame to open. Try clicking the button immediately,
+    // and if it is not yet visible, schedule a retry after a short delay.
+    var btn = document.querySelector(cls);
+    if (!btn) return { ok: false, error: "mode button not found: " + target };
+
+    function tryClick() {
+      var r = btn.getBoundingClientRect();
+      if (r.width > 0 && r.height > 0) {
+        click(btn);
+        return true;
+      }
+      return false;
+    }
+
+    if (tryClick()) return { ok: true, mode: target, switched: true };
+
+    // Return a promise that retries after the menu renders.
+    return wait(200).then(function () {
+      if (tryClick()) return { ok: true, mode: target, switched: true };
+      // Last resort: click even if not visually rendered.
+      click(btn);
+      return { ok: true, mode: target, switched: true, forced: true };
+    });
+  }
+
+  // ---------------------------------------------------------------------------
+  // Simplified API — interact with the simplified debugger view
+  // ---------------------------------------------------------------------------
+
+  function requireSimplifiedMode() {
+    var mode = detectMode();
+    if (mode === "simplified") return null;
+    if (mode === "advanced") return { error: "page is in advanced mode, not simplified" };
+    return { error: "cannot detect debugger mode" };
+  }
+
+  function findSendButton() {
+    // The Send button lives inside .ceres_main, which distinguishes it from
+    // any other "Send" button elsewhere on the page.
+    var container = document.querySelector(".ceres_main");
+    if (!container) return null;
+    var btns = container.querySelectorAll("a-button");
+    for (var i = 0; i < btns.length; i++) {
+      if (clean(btns[i].textContent) === "Send") return btns[i];
+    }
+    return null;
+  }
+
+  function findOutputSections() {
+    return Array.from(document.querySelectorAll(".ceres_output"));
+  }
+
+  function readOutputSection(el) {
+    var header = el.querySelector(".ceres_output_item_header_text");
+    var lines = el.querySelectorAll(".event__output_text");
+    var link = el.querySelector('a[href^="blob:"]');
+
+    return {
+      header: header ? clean(header.textContent) : null,
+      lines: Array.from(lines).map(function (l) {
+        return clean(l.textContent);
+      }),
+      lineCount: lines.length,
+      downloadLink: link
+        ? { text: clean(link.textContent), href: link.href }
+        : null,
+    };
+  }
+
+  var simplifiedApi = {
+    loadingFinished: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return false;
+      // The simplified view is ready when the log view has rendered items
+      // and the command textarea exists.
+      var hasLogItems =
+        document.querySelectorAll(".sized_item.vscroll__item").length > 0;
+      var hasTextarea = !!document.querySelector(
+        'textarea[placeholder*="Enter bash script"]',
+      );
+      return hasLogItems && hasTextarea;
+    },
+
+    loadingStatus: function () {
+      return {
+        url: window.location.href,
+        title: document.title,
+        mode: detectMode(),
+        logItemCount:
+          document.querySelectorAll(".sized_item.vscroll__item").length,
+        hasTextarea: !!document.querySelector(
+          'textarea[placeholder*="Enter bash script"]',
+        ),
+        hasSendButton: !!findSendButton(),
+        outputCount: findOutputSections().length,
+      };
+    },
+
+    waitForReady: function (options) {
+      return waitForReady(
+        function () {
+          return simplifiedApi.loadingFinished();
+        },
+        function () {
+          return simplifiedApi.loadingStatus();
+        },
+        options,
+      );
+    },
+
+    getMoment: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var input = document.querySelector("input.input_input");
+      if (!input) return { error: "moment input not found" };
+      // NOTE: this reads the input's displayed value, which is not the
+      // same as the committed moment used by runCommand. The page's
+      // click handler updates the committed moment synchronously but the
+      // displayed input value lags one cycle. For ground truth, read the
+      // `header` field returned by `waitForNewOutput` after a runCommand
+      // — that text is generated by the page from the actually-executed
+      // vtime. This getter is best-effort only.
+      return { ok: true, vtime: input.value };
+    },
+
+    getContainer: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var target = document.querySelector(".select_component_target");
+      if (!target) return { error: "container selector not found" };
+      return { ok: true, container: clean(target.textContent) };
+    },
+
+    getContainers: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var listbox = document.querySelector('[role="listbox"]');
+      if (!listbox) return { error: "container listbox not found" };
+      var items = listbox.querySelectorAll(
+        '[role="option"], a-button, div[tabindex]',
+      );
+      var names = [];
+      var seen = {};
+      items.forEach(function (el) {
+        var name = clean(el.textContent);
+        if (name && !seen[name]) {
+          seen[name] = true;
+          names.push(name);
+        }
+      });
+      return { ok: true, containers: names };
+    },
+
+    runCommand: function (script) {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var ta = document.querySelector(
+        'textarea[placeholder*="Enter bash script"]',
+      );
+      if (!ta) return { error: "command textarea not found" };
+      var sendBtn = findSendButton();
+      if (!sendBtn) return { error: "send button not found" };
+      setNativeValue(ta, script);
+      var countBefore = findOutputSections().length;
+      click(sendBtn);
+      return { ok: true, sent: true, script: script, outputCountBefore: countBefore };
+    },
+
+    extractFile: async function (path) {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+
+      // Toggle the extract file input if not already visible
+      var pathInput = document.querySelector(
+        'input[placeholder="/path/to/file_to_extract.ext"]',
+      );
+      if (!pathInput || !isVisible(pathInput)) {
+        var container = document.querySelector(".ceres_main");
+        var btns = container
+          ? container.querySelectorAll("a-button")
+          : document.querySelectorAll("a-button");
+        var toggleBtn = null;
+        for (var i = 0; i < btns.length; i++) {
+          if (clean(btns[i].textContent) === "Extract file") {
+            toggleBtn = btns[i];
+            break;
+          }
+        }
+        if (!toggleBtn) return { error: "extract file button not found" };
+        click(toggleBtn);
+        // Wait for the input to appear after toggle
+        await wait(300);
+      }
+
+      // Re-query after toggle
+      pathInput = document.querySelector(
+        'input[placeholder="/path/to/file_to_extract.ext"]',
+      );
+      if (!pathInput) return { error: "extract file input not found after toggle" };
+
+      var sendBtn = findSendButton();
+      if (!sendBtn) return { error: "send button not found" };
+
+      setNativeValue(pathInput, path);
+      var countBefore = findOutputSections().length;
+      click(sendBtn);
+      return { ok: true, sent: true, path: path, outputCountBefore: countBefore };
+    },
+
+    getOutputCount: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      return { ok: true, count: findOutputSections().length };
+    },
+
+    getLastOutput: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var outputs = findOutputSections();
+      if (outputs.length === 0) return { ok: false, error: "no output sections" };
+      var last = outputs[outputs.length - 1];
+      var result = readOutputSection(last);
+      result.ok = true;
+      result.index = outputs.length - 1;
+      return result;
+    },
+
+    getOutputHeaders: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var headers = document.querySelectorAll(".ceres_output_item_header_text");
+      return {
+        ok: true,
+        headers: Array.from(headers).map(function (h) {
+          return clean(h.textContent);
+        }),
+      };
+    },
+
+    getOutput: function (index) {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var outputs = findOutputSections();
+      if (index < 0 || index >= outputs.length) {
+        return { error: "output index out of range", count: outputs.length };
+      }
+      var result = readOutputSection(outputs[index]);
+      result.ok = true;
+      result.index = index;
+      return result;
+    },
+
+    waitForNewOutput: async function (countBefore, options) {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var timeoutMs =
+        options && typeof options.timeoutMs === "number"
+          ? options.timeoutMs
+          : 30000;
+      var intervalMs =
+        options && typeof options.intervalMs === "number"
+          ? options.intervalMs
+          : 1000;
+      var startedAt = Date.now();
+      var deadline = startedAt + timeoutMs;
+      var attempts = 0;
+
+      while (Date.now() <= deadline) {
+        attempts += 1;
+        var outputs = findOutputSections();
+        if (outputs.length > countBefore) {
+          var last = outputs[outputs.length - 1];
+          // Check that the output has finished loading (has lines or a link)
+          var lines = last.querySelectorAll(".event__output_text");
+          var link = last.querySelector('a[href^="blob:"]');
+          if (lines.length > 0 || link) {
+            var result = readOutputSection(last);
+            result.ok = true;
+            result.index = outputs.length - 1;
+            result.attempts = attempts;
+            result.waitedMs = Date.now() - startedAt;
+            return result;
+          }
+        }
+        if (Date.now() + intervalMs > deadline) break;
+        await wait(intervalMs);
+      }
+
+      return {
+        ok: false,
+        error: "timed out waiting for new output",
+        outputCount: findOutputSections().length,
+        countBefore: countBefore,
+        attempts: attempts,
+        waitedMs: Date.now() - startedAt,
+      };
+    },
+
+    filterLogs: function (query) {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var filterInput = document.querySelector(
+        'input[placeholder="Filter rows by text"]',
+      );
+      if (!filterInput) return { error: "filter input not found" };
+      setNativeValue(filterInput, query);
+      return { ok: true, query: query };
+    },
+
+    clearFilter: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var filterInput = document.querySelector(
+        'input[placeholder="Filter rows by text"]',
+      );
+      if (!filterInput) return { error: "filter input not found" };
+      setNativeValue(filterInput, "");
+      return { ok: true };
+    },
+
+    clickLogRow: function (index) {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var items = document.querySelectorAll(".sized_item.vscroll__item");
+      if (index < 0 || index >= items.length) {
+        return { error: "log row index out of range", count: items.length };
+      }
+      items[index].click();
+      return { ok: true, index: index };
+    },
+
+    getVisibleLogRows: function () {
+      var modeError = requireSimplifiedMode();
+      if (modeError) return modeError;
+      var items = document.querySelectorAll(".sized_item.vscroll__item");
+      var visible = [];
+      items.forEach(function (el, i) {
+        var rect = el.getBoundingClientRect();
+        if (rect.height > 0 && rect.y >= 0 && rect.y < 600) {
+          visible.push({
+            index: i,
+            text: clean(el.textContent).substring(0, 200),
+            isAnchor: !!el.closest(".vscroll_anchor_row"),
+          });
+        }
+      });
+      return { ok: true, rows: visible, totalCount: items.length };
+    },
+
+    // Find the events-log download anchor that lives in the visible Debug
+    // Timeline panel, distinguishing it from copies that live in hidden
+    // notebook overlays. After the page settles, multiple anchors with the
+    // same `download` attribute can exist in the DOM — some inside 0×0
+    // overlay wrappers, one inside the visible sequence_printer_wrapper.
+    // Returns the visible anchor or null. (Internal helper.)
+    _findVisibleEventsAnchor: function (format) {
+      var fmt = (format || "json").toLowerCase();
+      var filenameMap = { txt: "events.log", json: "events.json", csv: "events.csv" };
+      var filename = filenameMap[fmt];
+      if (!filename) return null;
+      var links = document.querySelectorAll(
+        'a.sequence_printer_menu_button[download="' + filename + '"]',
+      );
+      for (var i = 0; i < links.length; i++) {
+        var w = links[i].closest(".sequence_printer_wrapper");
+        if (!w) continue;
+        var r = w.getBoundingClientRect();
+        if (r.width > 0 && r.height > 0) return links[i];
+      }
+      return null;
+    },
+
+    // Returns true once a visible events-log anchor exists. The events panel
+    // renders later than the simplified view's top interaction area (which
+    // simplified.waitForReady checks), and after the page settles multiple
+    // copies of the events.* anchor may exist — only the one in the visible
+    // sequence_printer_wrapper indicates real readiness. Callers should poll
+    // this before prepareLogDownload.
+    //
+    // format: "txt" | "json" | "csv"  (default "json")
+    eventsLogReady: function (format) {
+      return !!this._findVisibleEventsAnchor(format);
+    },
+
+    // Prepare the events-log download anchor for capture by agent-browser.
+    //
+    // The MVD page renders multiple .sequence_printer_wrapper elements, but
+    // only the visible "Debug Timeline" panel carries events.{log,json,csv}
+    // download anchors; the hidden notebook overlays carry sequence-items.*
+    // instead. We locate the events anchor by its `download` attribute, force
+    // the 's shadow-root menu visible so the link is interactable,
+    // and tag the link with `data-mvd-dl="active"`. The caller then uses
+    //   agent-browser download 'a.sequence_printer_menu_button[data-mvd-dl]' 
+    // to capture the file.
+    //
+    // Synchronous — caller must poll eventsLogReady(format) first.
+    //
+    // format: "txt" | "json" | "csv"  (default "json")
+    prepareLogDownload: function (format) {
+      var fmt = (format || "json").toLowerCase();
+      var filenameMap = { txt: "events.log", json: "events.json", csv: "events.csv" };
+      var filename = filenameMap[fmt];
+      if (!filename) {
+        return { error: "unsupported format: " + format + "; use txt, json, or csv" };
+      }
+      var link = this._findVisibleEventsAnchor(fmt);
+      if (!link) {
+        return { error: "no visible events log anchor for format: " + fmt + " (poll eventsLogReady first)" };
+      }
+      var aMenu = link.closest("a-menu");
+      var shadowMenu =
+        aMenu && aMenu.shadowRoot && aMenu.shadowRoot.querySelector("menu");
+      if (!shadowMenu) {
+        return { error: "shadow menu not found for download link" };
+      }
+      shadowMenu.style.display = "flex";
+      shadowMenu.style.position = "fixed";
+      shadowMenu.style.top = "0";
+      shadowMenu.style.left = "0";
+      shadowMenu.style.zIndex = "99999";
+      document.querySelectorAll("[data-mvd-dl]").forEach(function (e) {
+        e.removeAttribute("data-mvd-dl");
+      });
+      link.setAttribute("data-mvd-dl", "active");
+      return {
+        ok: true,
+        format: fmt,
+        filename: filename,
+        selector: 'a.sequence_printer_menu_button[data-mvd-dl]',
+      };
+    },
+  };
+
+  // ---------------------------------------------------------------------------
+  // Cell helpers — advanced mode notebook cells
+  // ---------------------------------------------------------------------------
+
+  function cellActionButton(cell) {
+    return cell.querySelector("a-button.action_auth");
+  }
+
+  function cellIsActionAuthorized(cell) {
+    var btn = cellActionButton(cell);
+    return btn ? btn.hasAttribute("disabled") : false;
+  }
+
+  function cellStatusLabel(cell) {
+    var el = cell.querySelector(".action_status_label");
+    return el ? clean(el.innerText || el.textContent) : null;
+  }
+
+  function cellItemCount(cell) {
+    var el = cell.querySelector(".sequence_toolbar__items-counter");
+    return el ? clean(el.innerText || el.textContent) : null;
+  }
+
+  function cellEvents(cell) {
+    return Array.from(cell.querySelectorAll(".event")).map(function (ev) {
+      return clean(ev.innerText || ev.textContent);
+    });
+  }
+
+  function cellOutput(cell) {
+    var statusLabel = cellStatusLabel(cell);
+    var itemCount = cellItemCount(cell);
+    var events = cellEvents(cell);
+
+    if (!statusLabel && events.length === 0) return null;
+
+    return {
+      statusLabel: statusLabel,
+      itemCount: itemCount,
+      eventCount: events.length,
+      events: events,
+    };
+  }
+
+  function cellButtonLabel(cell) {
+    var btn = cellActionButton(cell);
+    if (!btn) return null;
+    var label = btn.querySelector("label");
+    return label ? clean(label.textContent) : clean(btn.innerText);
+  }
+
+  function cellHasCompleted(cell) {
+    if (!cellIsActionAuthorized(cell)) return false;
+    var status = cellStatusLabel(cell);
+    if (!status) return false;
+    return !/^running\b/i.test(status);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Notebook API (advanced mode) — interact with the Monaco editor
+  // ---------------------------------------------------------------------------
+
+  var notebookApi = {
+    loadingFinished: function () {
+      return !!(
+        window.editor &&
+        typeof window.editor.getValue === "function" &&
+        window.editor.getValue().length > 0 &&
+        document.querySelectorAll(".cell").length > 0
+      );
+    },
+
+    loadingStatus: function () {
+      return {
+        url: window.location.href,
+        title: document.title,
+        mode: detectMode(),
+        hasEditor: !!(window.editor && typeof window.editor.getValue === "function"),
+        hasEditorNotebook: !!window.editor_notebook,
+        editorContentLength:
+          window.editor && typeof window.editor.getValue === "function"
+            ? window.editor.getValue().length
+            : 0,
+        cellCount: document.querySelectorAll(".cell").length,
+        visibleCells: Array.from(document.querySelectorAll(".cell")).filter(isVisible).length,
+        windowKeys: Object.keys(window).filter(function (k) {
+          return /editor|notebook|multiverse|debug/i.test(k);
+        }),
+      };
+    },
+
+    waitForReady: function (options) {
+      return waitForReady(
+        function () {
+          return notebookApi.loadingFinished();
+        },
+        function () {
+          return notebookApi.loadingStatus();
+        },
+        options,
+      );
+    },
+
+    getSource: function () {
+      if (!window.editor || typeof window.editor.getValue !== "function") {
+        return { error: "editor not available" };
+      }
+
+      return {
+        ok: true,
+        source: window.editor.getValue(),
+        length: window.editor.getValue().length,
+      };
+    },
+
+    setSource: function (code) {
+      if (!window.editor || typeof window.editor.setValue !== "function") {
+        return { error: "editor not available" };
+      }
+
+      window.editor.setValue(code);
+      return { ok: true, length: code.length };
+    },
+
+    appendSource: function (code) {
+      if (
+        !window.editor ||
+        typeof window.editor.getValue !== "function" ||
+        typeof window.editor.setValue !== "function"
+      ) {
+        return { error: "editor not available" };
+      }
+
+      var current = window.editor.getValue();
+      var separator = current.endsWith("\n") ? "" : "\n";
+      var newSource = current + separator + code;
+      window.editor.setValue(newSource);
+      return { ok: true, length: newSource.length, appended: code.length };
+    },
+
+    getCells: function () {
+      var cells = Array.from(document.querySelectorAll(".cell"));
+      return cells.map(function (cell, index) {
+        var hasAction = !!cellActionButton(cell);
+
+        return {
+          index: index,
+          text: clean(cell.innerText || cell.textContent).substring(0, 500),
+          hasAction: hasAction,
+          actionAuthorized: hasAction ? cellIsActionAuthorized(cell) : null,
+          actionCompleted: hasAction ? cellHasCompleted(cell) : null,
+          statusLabel: hasAction ? cellStatusLabel(cell) : null,
+          output: cellOutput(cell),
+          visible: isVisible(cell),
+        };
+      });
+    },
+
+    getCellCount: function () {
+      return document.querySelectorAll(".cell").length;
+    },
+  };
+
+  // ---------------------------------------------------------------------------
+  // Actions API (advanced mode) — authorize and read shell action cells
+  // ---------------------------------------------------------------------------
+
+  function findCellByContent(textMatch) {
+    return Array.from(document.querySelectorAll(".cell")).find(function (cell) {
+      return (cell.innerText || cell.textContent || "").includes(textMatch);
+    });
+  }
+
+  var actionsApi = {
+    getAll: function () {
+      var cells = Array.from(document.querySelectorAll(".cell"));
+      var actions = [];
+
+      cells.forEach(function (cell, index) {
+        var btn = cellActionButton(cell);
+        if (!btn) return;
+
+        actions.push({
+          cellIndex: index,
+          buttonText: cellButtonLabel(cell),
+          authorized: cellIsActionAuthorized(cell),
+          completed: cellHasCompleted(cell),
+          statusLabel: cellStatusLabel(cell),
+          visible: isVisible(btn),
+        });
+      });
+
+      return actions;
+    },
+
+    authorizeByIndex: function (cellIndex) {
+      var cells = Array.from(document.querySelectorAll(".cell"));
+      if (cellIndex < 0 || cellIndex >= cells.length) {
+        return { error: "cell index out of range", count: cells.length };
+      }
+
+      var cell = cells[cellIndex];
+      var btn = cellActionButton(cell);
+      if (!btn) {
+        return { error: "no action button in cell", cellIndex: cellIndex };
+      }
+
+      return { clicked: click(btn), cellIndex: cellIndex };
+    },
+
+    authorizeByContent: function (textMatch) {
+      var target = findCellByContent(textMatch);
+
+      if (!target) {
+        return { error: "no cell matching text", query: textMatch };
+      }
+
+      var btn = cellActionButton(target);
+      if (!btn) {
+        return {
+          error: "matching cell has no action button",
+          query: textMatch,
+        };
+      }
+
+      return { clicked: click(btn), query: textMatch };
+    },
+
+    authorizeAll: async function () {
+      var cells = Array.from(document.querySelectorAll(".cell"));
+      var authorized = 0;
+
+      for (var i = 0; i < cells.length; i++) {
+        var btn = cellActionButton(cells[i]);
+        if (btn && !btn.hasAttribute("disabled") && click(btn)) {
+          authorized++;
+          await wait(500);
+        }
+      }
+
+      return { authorized: authorized, totalCells: cells.length };
+    },
+
+    getResult: function (textMatch) {
+      var target = findCellByContent(textMatch);
+      if (!target) {
+        return { error: "no cell matching text", query: textMatch };
+      }
+
+      if (!cellHasCompleted(target)) {
+        return {
+          ok: false,
+          completed: false,
+          authorized: cellIsActionAuthorized(target),
+          query: textMatch,
+        };
+      }
+
+      var output = cellOutput(target);
+      return {
+        ok: true,
+        completed: true,
+        statusLabel: output ? output.statusLabel : null,
+        itemCount: output ? output.itemCount : null,
+        eventCount: output ? output.eventCount : 0,
+        events: output ? output.events : [],
+        query: textMatch,
+      };
+    },
+
+    waitForResult: async function (textMatch, options) {
+      var timeoutMs =
+        options && typeof options.timeoutMs === "number" ? options.timeoutMs : 30000;
+      var intervalMs =
+        options && typeof options.intervalMs === "number" ? options.intervalMs : 1000;
+      var startedAt = Date.now();
+      var deadline = startedAt + timeoutMs;
+      var attempts = 0;
+
+      while (Date.now() <= deadline) {
+        attempts += 1;
+
+        var target = findCellByContent(textMatch);
+        if (target && cellHasCompleted(target)) {
+          var output = cellOutput(target);
+          return {
+            ok: true,
+            statusLabel: output ? output.statusLabel : null,
+            itemCount: output ? output.itemCount : null,
+            eventCount: output ? output.eventCount : 0,
+            events: output ? output.events : [],
+            attempts: attempts,
+            waitedMs: Date.now() - startedAt,
+          };
+        }
+
+        if (Date.now() + intervalMs > deadline) break;
+        await wait(intervalMs);
+      }
+
+      return {
+        ok: false,
+        error: "timed out waiting for result",
+        query: textMatch,
+        authorized: target ? cellIsActionAuthorized(target) : false,
+        attempts: attempts,
+        waitedMs: Date.now() - startedAt,
+      };
+    },
+  };
+
+  // ---------------------------------------------------------------------------
+  // Public API
+  // ---------------------------------------------------------------------------
+
+  var api = {
+    __version: VERSION,
+    getMode: detectMode,
+    switchMode: switchMode,
+    simplified: simplifiedApi,
+    notebook: notebookApi,
+    actions: actionsApi,
+    info: function () {
+      return {
+        ok: true,
+        version: VERSION,
+        mode: detectMode(),
+        description: "Antithesis multiverse debugger runtime",
+        namespaces: {
+          simplified: Object.keys(simplifiedApi).sort(),
+          notebook: Object.keys(notebookApi).sort(),
+          actions: Object.keys(actionsApi).sort(),
+        },
+      };
+    },
+  };
+
+  window.__antithesisDebug = api;
+  return api.info();
+})();
diff --git a/.claude/skills/antithesis-debug/assets/download-mvd-log.sh b/.claude/skills/antithesis-debug/assets/download-mvd-log.sh
new file mode 100755
index 0000000..7dc55a5
--- /dev/null
+++ b/.claude/skills/antithesis-debug/assets/download-mvd-log.sh
@@ -0,0 +1,158 @@
+#!/usr/bin/env bash
+#
+# download-mvd-log.sh — Download the events log from a Multiverse Debugger
+# (MVD) report page via agent-browser, then (optionally) annotate it with
+# active-faults metadata using process-logs.py.
+#
+# Usage:
+#   download-mvd-log.sh --url  -o PATH [--format json|txt|csv] [--raw]
+#
+# Defaults: --format json. Annotation is applied for json output unless --raw
+# is passed. txt and csv outputs are written verbatim.
+#
+# Requires: agent-browser, jq, python3 (for json post-processing).
+#
+# The script:
+#   1. Opens the debugging-session URL with shared antithesis auth.
+#   2. Verifies the URL is on a /debugging-session/... path.
+#   3. Injects assets/antithesis-debug.js, calls
+#      window.__antithesisDebug.simplified.prepareLogDownload(format) to
+#      locate the events. anchor, force the shadow-root menu visible,
+#      and tag the link.
+#   4. Captures the click via `agent-browser download  `.
+#   5. For json: pipes through process-logs.py to add vtime_seconds and
+#      active_faults annotations. For txt/csv or --raw: moves the temp file
+#      to OUTPUT verbatim.
+#
+# Exit codes:
+#   0  success
+#   2  did not land on a debugging-session page (auth or wrong URL)
+#   3  download or processing error
+#   4  usage error
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+RUNTIME_JS="${SCRIPT_DIR}/antithesis-debug.js"
+PROCESS_LOGS="${SCRIPT_DIR}/process-logs.py"
+
+usage() {
+  sed -n '3,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^#\s\?//' >&2
+  exit 4
+}
+
+URL=""
+OUTPUT=""
+FORMAT="json"
+RAW=0
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --url)         URL="$2"; shift 2 ;;
+    -o|--output)   OUTPUT="$2"; shift 2 ;;
+    --format)      FORMAT="$2"; shift 2 ;;
+    --raw)         RAW=1; shift ;;
+    -h|--help)     usage ;;
+    *)             echo "Error: unknown option: $1" >&2; usage ;;
+  esac
+done
+
+[[ -z "$URL" ]] && { echo "Error: --url is required" >&2; usage; }
+[[ -z "$OUTPUT" ]] && { echo "Error: -o/--output is required" >&2; usage; }
+
+case "$FORMAT" in
+  json|txt|csv) ;;
+  *) echo "Error: --format must be json, txt, or csv" >&2; usage ;;
+esac
+
+for tool in agent-browser jq python3; do
+  command -v "$tool" >/dev/null 2>&1 || { echo "Error: $tool not found in PATH" >&2; exit 4; }
+done
+
+mkdir -p "$(dirname "$OUTPUT")"
+
+SESSION="antithesis-debug-dl-$(date +%s)-$$"
+TMPFILE=$(mktemp)
+
+cleanup() {
+  agent-browser --session "$SESSION" close >/dev/null 2>&1 || true
+  rm -f "$TMPFILE"
+}
+trap cleanup EXIT
+
+# Step 1: open URL with shared auth
+echo "Opening MVD URL..." >&2
+if ! agent-browser --session "$SESSION" --session-name antithesis open "$URL" >/dev/null 2>&1; then
+  echo "Error: failed to open URL" >&2
+  exit 3
+fi
+
+# Step 2: wait for load, verify we landed on the debug page
+agent-browser --session "$SESSION" wait --load networkidle >/dev/null 2>&1 || true
+CURRENT_URL=$(agent-browser --session "$SESSION" get url 2>/dev/null || echo "unknown")
+if [[ "$CURRENT_URL" != *"/debugging-session/"* ]]; then
+  echo "Error: did not land on a debugging-session page (at: $CURRENT_URL)" >&2
+  echo "If redirected to a login page, authenticate first using the antithesis-agent-browser skill's setup-auth flow." >&2
+  exit 2
+fi
+
+# Step 3: inject runtime, wait for ready, prepare the download
+echo "Injecting runtime..." >&2
+if ! cat "$RUNTIME_JS" | agent-browser --session "$SESSION" eval --stdin >/dev/null 2>&1; then
+  echo "Error: failed to inject debug runtime" >&2
+  exit 3
+fi
+
+echo "Waiting for simplified view to be ready..." >&2
+if ! agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.simplified.waitForReady()' >/dev/null 2>&1; then
+  echo "Error: simplified view did not become ready" >&2
+  exit 3
+fi
+
+echo "Waiting for events log panel to render..." >&2
+DEADLINE=$(( $(date +%s) + 30 ))
+while :; do
+  READY=$(agent-browser --session "$SESSION" eval \
+    "window.__antithesisDebug.simplified.eventsLogReady('${FORMAT}')" 2>/dev/null | tail -1)
+  [[ "$READY" == "true" ]] && break
+  if [[ $(date +%s) -ge $DEADLINE ]]; then
+    echo "Error: events log panel did not render within 30s" >&2
+    exit 3
+  fi
+  sleep 1
+done
+
+echo "Preparing download (format: $FORMAT)..." >&2
+PREPARE_RESULT=$(agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.prepareLogDownload('${FORMAT}')" 2>&1)
+if echo "$PREPARE_RESULT" | jq -e '.error' >/dev/null 2>&1; then
+  ERR=$(echo "$PREPARE_RESULT" | jq -r '.error')
+  echo "Error: prepareLogDownload failed: $ERR" >&2
+  exit 3
+fi
+
+# Step 4: capture the download
+echo "Downloading..." >&2
+if ! agent-browser --session "$SESSION" download \
+  'a.sequence_printer_menu_button[data-mvd-dl]' "$TMPFILE" >/dev/null 2>&1; then
+  echo "Error: download failed" >&2
+  exit 3
+fi
+if [[ ! -s "$TMPFILE" ]]; then
+  echo "Error: downloaded file is empty" >&2
+  exit 3
+fi
+
+# Step 5: post-process or move
+if [[ "$FORMAT" == "json" && "$RAW" -eq 0 ]]; then
+  echo "Annotating logs with vtime_seconds and active_faults..." >&2
+  if ! python3 "$PROCESS_LOGS" "$TMPFILE" -o "$OUTPUT"; then
+    echo "Error: process-logs.py failed" >&2
+    exit 3
+  fi
+else
+  mv "$TMPFILE" "$OUTPUT"
+fi
+
+echo "Wrote: $OUTPUT" >&2
diff --git a/.claude/skills/antithesis-debug/assets/process-logs.py b/.claude/skills/antithesis-debug/assets/process-logs.py
new file mode 100644
index 0000000..ac87102
--- /dev/null
+++ b/.claude/skills/antithesis-debug/assets/process-logs.py
@@ -0,0 +1,1389 @@
+#!/usr/bin/env python3
+"""Process an Antithesis JSON log: strip ANSI escapes, add vtime_seconds, track active faults.
+
+Transformations applied to each event:
+  1. Strip ANSI escape codes from output_text fields.
+  2. Add vtime_seconds (rounded to 5 decimal places).
+  3. Add active_faults dict tracking currently open fault windows:
+     - network_partition / network_clog: outer boundary of overlapping network faults
+     - node_pause / node_throttle: per-container node fault windows
+     - clock_skip: cumulative clock offset (permanent + active jitter)
+
+Usage: python3 process-logs.py < events.json > processed.json
+       python3 process-logs.py events.json -o processed.json
+       python3 process-logs.py events.json  # prints to stdout
+       python3 process-logs.py --test       # run unit tests
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from dataclasses import dataclass
+
+ANSI_RE = re.compile(
+    r"\x1b\[[\x20-\x3f]*[\x40-\x7e]"  # CSI: ESC [ ... final
+    r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"  # OSC: ESC ] ... (BEL | ESC \)
+    r"|\x1b[\x20-\x7e]"  # two-byte: ESC + single printable
+)
+
+VTIME_DIVISOR = 4294967296  # 2^32, for legacy moment._vtime_ticks logs
+
+NETWORK_FAULTS = {"partition", "clog"}
+TRACKED_NODE_FAULTS = {"pause", "throttle"}
+
+
+@dataclass
+class FaultWindow:
+    fault_type: str  # "network", "node", or "clock"
+    fault_name: str  # "partition", "clog", "pause", "throttle", "skip"
+    start_vtime: float
+    end_vtime: float | None  # None = no natural end (permanent clock offsets)
+    container: str | None  # for node faults only
+    offset: float  # for clock/skip only (0.0 for non-clock)
+
+
+def strip_ansi(text):
+    return ANSI_RE.sub("", text)
+
+
+def _valid_max_duration(max_dur):
+    """Return max_dur as a positive float, or None if absent/zero/string."""
+    if isinstance(max_dur, (int, float)) and max_dur > 0:
+        return float(max_dur)
+    return None
+
+
+def _build_active_faults(windows):
+    """Reduce internal fault windows into the active_faults snapshot."""
+    result = {}
+    for w in windows:
+        if w.fault_type == "network":
+            result[f"network_{w.fault_name}"] = {"vtime": w.start_vtime}
+        elif w.fault_type == "node":
+            key = f"node_{w.fault_name}"
+            if key not in result:
+                result[key] = {}
+            result[key][w.container] = w.start_vtime
+    # Clock: sum offsets of all active clock windows, use most recent vtime
+    clock_windows = [w for w in windows if w.fault_type == "clock"]
+    if clock_windows:
+        cumulative = sum(w.offset for w in clock_windows)
+        latest_vtime = max(w.start_vtime for w in clock_windows)
+        if cumulative != 0.0:
+            result["clock_skip"] = {"cumulative_offset": cumulative, "vtime": latest_vtime}
+    return result
+
+
+def process_events(events):
+    """Process all events in a single pass: strip ANSI, add vtime_seconds, track active_faults."""
+    fault_windows = []
+    faults_snapshot = {}
+    faults_dirty = True
+    result = []
+
+    for event in events:
+        processed = dict(event)
+
+        # Strip ANSI from output_text
+        output_text = processed.get("output_text")
+        if isinstance(output_text, str) and "\x1b" in output_text:
+            processed["output_text"] = strip_ansi(output_text)
+
+        # Compute vtime_seconds. Prefer moment.vtime (string seconds, API format);
+        # fall back to moment._vtime_ticks (integer ticks, legacy format).
+        moment = processed.get("moment")
+        vtime = None
+        if isinstance(moment, dict):
+            if "vtime" in moment:
+                try:
+                    vtime = round(float(moment["vtime"]), 5)
+                except (TypeError, ValueError):
+                    vtime = None
+            elif "_vtime_ticks" in moment:
+                try:
+                    vtime = round(moment["_vtime_ticks"] / VTIME_DIVISOR, 5)
+                except (TypeError, ZeroDivisionError):
+                    vtime = None
+            if vtime is not None:
+                processed["vtime_seconds"] = vtime
+
+        ev_vtime = vtime if vtime is not None else 0.0
+
+        # Only process fault/info events from the fault injector source.
+        source = processed.get("source")
+        is_fault_injector = isinstance(source, dict) and source.get("name") == "fault_injector"
+
+        # --- Stage 1: Expire windows ---
+        if vtime is not None and fault_windows:
+            before = len(fault_windows)
+            fault_windows[:] = [
+                w for w in fault_windows if w.end_vtime is None or w.end_vtime > vtime
+            ]
+            if len(fault_windows) != before:
+                faults_dirty = True
+
+        # --- Stage 2: Info events ---
+        info = processed.get("info")
+        if is_fault_injector and isinstance(info, dict) and info.get("message") == "status":
+            details = info.get("details", {})
+            if details.get("paused") is True:
+                # Clear network and node windows; clock windows survive pause
+                before = len(fault_windows)
+                fault_windows[:] = [w for w in fault_windows if w.fault_type == "clock"]
+                if len(fault_windows) != before:
+                    faults_dirty = True
+
+        # --- Stage 3: Fault events ---
+        fault = processed.get("fault")
+        if is_fault_injector and isinstance(fault, dict):
+            fault_name = fault.get("name")
+            fault_type = fault.get("type")
+            affected = fault.get("affected_nodes") or []
+            max_dur = _valid_max_duration(fault.get("max_duration"))
+
+            # Network faults: outer boundary tracking
+            if fault_name in NETWORK_FAULTS and affected:
+                new_end = (ev_vtime + max_dur) if max_dur else None
+                # After expiration, any remaining window of same name is active
+                existing = next(
+                    (
+                        w
+                        for w in fault_windows
+                        if w.fault_type == "network" and w.fault_name == fault_name
+                    ),
+                    None,
+                )
+                if existing is not None:
+                    # Extend if new fault reaches beyond existing
+                    if existing.end_vtime is not None and (
+                        new_end is None or new_end > existing.end_vtime
+                    ):
+                        existing.end_vtime = new_end
+                        faults_dirty = True
+                    # else: completely covered, ignore
+                else:
+                    fault_windows.append(
+                        FaultWindow(
+                            fault_type="network",
+                            fault_name=fault_name,
+                            start_vtime=ev_vtime,
+                            end_vtime=new_end,
+                            container=None,
+                            offset=0.0,
+                        )
+                    )
+                    faults_dirty = True
+
+            elif fault_name == "restore":
+                before = len(fault_windows)
+                fault_windows[:] = [w for w in fault_windows if w.fault_type != "network"]
+                if len(fault_windows) != before:
+                    faults_dirty = True
+
+            elif fault_name in TRACKED_NODE_FAULTS and fault_type == "node":
+                if affected:
+                    container = affected[0]
+                    end_vt = (ev_vtime + max_dur) if max_dur else None
+                    fault_windows.append(
+                        FaultWindow(
+                            fault_type="node",
+                            fault_name=fault_name,
+                            start_vtime=ev_vtime,
+                            end_vtime=end_vt,
+                            container=container,
+                            offset=0.0,
+                        )
+                    )
+                    faults_dirty = True
+
+            elif fault_name == "skip" and fault_type == "clock":
+                det = fault.get("details")
+                offset = det.get("offset", 0.0) if isinstance(det, dict) else 0.0
+                if offset != 0.0:
+                    end_vt = (ev_vtime + max_dur) if max_dur else None
+                    fault_windows.append(
+                        FaultWindow(
+                            fault_type="clock",
+                            fault_name="skip",
+                            start_vtime=ev_vtime,
+                            end_vtime=end_vt,
+                            container=None,
+                            offset=offset,
+                        )
+                    )
+                    faults_dirty = True
+
+        # --- Stage 4: Reduce to snapshot ---
+        if faults_dirty:
+            faults_snapshot = _build_active_faults(fault_windows)
+            faults_dirty = False
+        processed["active_faults"] = faults_snapshot
+
+        result.append(processed)
+
+    return result
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Process an Antithesis JSON log: strip ANSI escapes, add vtime_seconds, track active faults.",
+    )
+    parser.add_argument("input", nargs="?", help="input JSON log (default: stdin)")
+    parser.add_argument("-o", "--output", help="output file (default: stdout)")
+    parser.add_argument("--test", action="store_true", help="run unit tests")
+    args = parser.parse_args()
+
+    if args.test:
+        return run_tests()
+
+    src = open(args.input) if args.input else sys.stdin
+    try:
+        events = json.load(src)
+    finally:
+        if src is not sys.stdin:
+            src.close()
+
+    events = process_events(events)
+
+    dst = open(args.output, "w") if args.output else sys.stdout
+    try:
+        json.dump(events, dst, ensure_ascii=False)
+        dst.write("\n")
+    finally:
+        if dst is not sys.stdout:
+            dst.close()
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+def _evt(vtime_sec, **kwargs):
+    """Helper to create a test event at a given virtual time in seconds.
+
+    Automatically adds source.name = "fault_injector" when fault or info keys
+    are present, since process_events only processes those from that source.
+    """
+    event = {"moment": {"vtime": format(vtime_sec, ".10f")}, **kwargs}
+    if "fault" in event or "info" in event:
+        event.setdefault("source", {"name": "fault_injector"})
+    return event
+
+
+def run_tests():
+    failures = 0
+
+    def check(name, input_text, expected=None):
+        nonlocal failures
+        if expected is None:
+            expected = input_text
+        result = strip_ansi(input_text)
+        if result != expected:
+            print(f"FAIL: {name}")
+            print(f"  input:    {input_text!r}")
+            print(f"  expected: {expected!r}")
+            print(f"  got:      {result!r}")
+            failures += 1
+        else:
+            print(f"  ok: {name}")
+
+    def assert_eq(name, got, expected):
+        nonlocal failures
+        if got != expected:
+            print(f"FAIL: {name}")
+            print(f"  expected: {expected!r}")
+            print(f"  got:      {got!r}")
+            failures += 1
+        else:
+            print(f"  ok: {name}")
+
+    # -- SGR (colors, bold, etc) --
+    check("bold + reset", "\x1b[1mbold\x1b[0m", "bold")
+    check("256 color", "\x1b[38;5;196mred\x1b[0m", "red")
+    check("RGB color", "\x1b[38;2;255;0;0mred\x1b[0m", "red")
+    check("multiple SGR params", "\x1b[1;31;42mtext\x1b[0m", "text")
+    check(
+        "typical tracing-subscriber line",
+        "\x1b[2m2026-04-03T08:19:54Z\x1b[0m \x1b[32m INFO\x1b[0m"
+        " \x1b[2mfoobar\x1b[0m\x1b[2m:\x1b[0m ready",
+        "2026-04-03T08:19:54Z  INFO foobar: ready",
+    )
+
+    # -- CSI non-SGR (cursor, erase, DEC private) --
+    check("cursor up", "left\x1b[2Aright", "leftright")
+    check("erase line", "text\x1b[2K", "text")
+    check("DEC show cursor", "\x1b[?25hvisible", "visible")
+    check("DEC hide cursor", "\x1b[?25l hidden", " hidden")
+
+    # -- OSC sequences --
+    check("OSC window title (BEL terminated)", "\x1b]0;my window title\x07text after", "text after")
+    check("OSC window title (ST terminated)", "\x1b]0;my title\x1b\\text after", "text after")
+
+    # -- Two-byte escapes --
+    check("ESC c (reset)", "\x1bcafter reset", "after reset")
+    check("ESC 7 (save cursor)", "before\x1b7after", "beforeafter")
+
+    # -- Must NOT damage these --
+    check("plain text passthrough", "no escapes here")
+    check("inline JSON preserved", '{"key": "value", "nested": {"a": [1,2,3]}}')
+    check("JSON with special chars", '{"url": "http://example.com/path?q=1&r=2", "count": 42}')
+    check(
+        "Rust Debug struct preserved",
+        'Options { address: Some(0.0.0.0:3307), deployment: "mydb", mode: Standalone }',
+    )
+    check(
+        "Rust Debug with nested braces",
+        'Config { inner: Inner { values: [1, 2, 3] }, name: "test" }',
+    )
+    check("square brackets in text", "[2026-04-03] [INFO] [main] started")
+    check("backslash in paths", 'path: "/nix/store/abc-pkg/bin/cmd"')
+    check("escaped quotes in JSON", '{"msg": "he said \\"hello\\""}')
+
+    # -- Mixed: escapes around JSON/structs --
+    check("escape codes wrapping JSON", '\x1b[2m{"key": "value"}\x1b[0m', '{"key": "value"}')
+    check(
+        "escape codes wrapping Rust Debug",
+        "\x1b[3mOptions { mode: Standalone }\x1b[0m",
+        "Options { mode: Standalone }",
+    )
+    check(
+        "tracing line with JSON payload",
+        "\x1b[2m2026-04-03T00:00:00Z\x1b[0m \x1b[32m INFO\x1b[0m"
+        ' request completed {"status": 200, "latency_ms": 42}',
+        '2026-04-03T00:00:00Z  INFO request completed {"status": 200, "latency_ms": 42}',
+    )
+
+    # -- Event-level: ANSI stripping via process_events --
+    evt = {
+        "output_text": "\x1b[1mhi\x1b[0m",
+        "fault": {"name": "kill"},
+        "other": "\x1b[1mkeep\x1b[0m",
+    }
+    results = process_events([evt])
+    assert_eq("process_events strips output_text", results[0]["output_text"], "hi")
+    assert_eq("process_events does not mutate input", evt["output_text"], "\x1b[1mhi\x1b[0m")
+    assert_eq("process_events preserves other fields", results[0]["other"], "\x1b[1mkeep\x1b[0m")
+
+    # -- vtime_seconds computation --
+    print()
+    print("vtime_seconds tests:")
+    evt_with_moment = {"moment": {"vtime": "1.0"}}
+    results = process_events([evt_with_moment])
+    assert_eq('vtime_seconds = 1.0 from "1.0"', results[0]["vtime_seconds"], 1.0)
+
+    evt_half = {"moment": {"vtime": "0.5"}}
+    results = process_events([evt_half])
+    assert_eq('vtime_seconds = 0.5 from "0.5"', results[0]["vtime_seconds"], 0.5)
+
+    evt_precise = {"moment": {"vtime": "2.87432918734"}}
+    results = process_events([evt_precise])
+    assert_eq("vtime_seconds rounded to 5 places", results[0]["vtime_seconds"], 2.87433)
+    vtime_str = str(results[0]["vtime_seconds"])
+    parts = vtime_str.split(".")
+    assert_eq("vtime_seconds decimal precision <= 5", len(parts[1]) <= 5, True)
+
+    evt_zero = {"moment": {"vtime": "0"}}
+    results = process_events([evt_zero])
+    assert_eq('vtime_seconds = 0.0 from "0"', results[0]["vtime_seconds"], 0.0)
+
+    evt_bad = {"moment": {"vtime": "not-a-number"}}
+    results = process_events([evt_bad])
+    assert_eq("no vtime_seconds when vtime unparsable", "vtime_seconds" not in results[0], True)
+
+    # Legacy format: moment._vtime_ticks (integer ticks, 2^32 per second)
+    evt_legacy = {"moment": {"_vtime_ticks": 4294967296}}  # 2^32 ticks => 1.0s
+    results = process_events([evt_legacy])
+    assert_eq("legacy _vtime_ticks: 2^32 => 1.0s", results[0]["vtime_seconds"], 1.0)
+
+    evt_legacy_half = {"moment": {"_vtime_ticks": 2147483648}}  # 2^31 ticks => 0.5s
+    results = process_events([evt_legacy_half])
+    assert_eq("legacy _vtime_ticks: 2^31 => 0.5s", results[0]["vtime_seconds"], 0.5)
+
+    # If both fields are present, vtime wins (API format takes precedence)
+    evt_both = {"moment": {"vtime": "2.0", "_vtime_ticks": 4294967296}}
+    results = process_events([evt_both])
+    assert_eq("vtime takes precedence over _vtime_ticks", results[0]["vtime_seconds"], 2.0)
+
+    # Event without moment field should not get vtime_seconds
+    evt_no_moment = {"output_text": "hello"}
+    results = process_events([evt_no_moment])
+    assert_eq("no vtime_seconds when no moment", "vtime_seconds" not in results[0], True)
+    assert_eq("active_faults present even without moment", "active_faults" in results[0], True)
+
+    # =====================================================================
+    # active_faults: network faults
+    # =====================================================================
+    print()
+    print("active_faults: network faults")
+
+    # Partition opens a fault window
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+        ]
+    )
+    assert_eq(
+        "partition opens fault window",
+        results[0]["active_faults"],
+        {"network_partition": {"vtime": 1.0}},
+    )
+
+    # Clog opens a fault window
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "clog",
+                    "type": "network",
+                    "affected_nodes": ["node-1"],
+                    "max_duration": 100,
+                },
+            ),
+        ]
+    )
+    assert_eq(
+        "clog opens fault window", results[0]["active_faults"], {"network_clog": {"vtime": 1.0}}
+    )
+
+    # Restore closes network faults
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(2.0, fault={"name": "restore", "type": "network", "affected_nodes": ["ALL"]}),
+        ]
+    )
+    assert_eq(
+        "partition visible before restore",
+        results[0]["active_faults"],
+        {"network_partition": {"vtime": 1.0}},
+    )
+    assert_eq("restore clears network faults", results[1]["active_faults"], {})
+
+    # Multiple concurrent network faults (partition + clog)
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(
+                2.0,
+                fault={
+                    "name": "clog",
+                    "type": "network",
+                    "affected_nodes": ["node-1"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(3.0, output_text="normal event"),
+            _evt(4.0, fault={"name": "restore", "type": "network", "affected_nodes": ["ALL"]}),
+        ]
+    )
+    assert_eq("partition only", results[0]["active_faults"], {"network_partition": {"vtime": 1.0}})
+    assert_eq(
+        "partition + clog",
+        results[1]["active_faults"],
+        {"network_partition": {"vtime": 1.0}, "network_clog": {"vtime": 2.0}},
+    )
+    assert_eq(
+        "both still active on normal event",
+        results[2]["active_faults"],
+        {"network_partition": {"vtime": 1.0}, "network_clog": {"vtime": 2.0}},
+    )
+    assert_eq("restore clears both", results[3]["active_faults"], {})
+
+    # Empty affected_nodes is a no-op (doesn't close existing window)
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "clog",
+                    "type": "network",
+                    "affected_nodes": ["node-1"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(2.0, fault={"name": "clog", "type": "network", "affected_nodes": []}),
+        ]
+    )
+    assert_eq(
+        "clog with nodes opens window",
+        results[0]["active_faults"],
+        {"network_clog": {"vtime": 1.0}},
+    )
+    assert_eq("empty nodes is noop", results[1]["active_faults"], {"network_clog": {"vtime": 1.0}})
+
+    # Missing affected_nodes is a no-op
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(2.0, fault={"name": "partition", "type": "network"}),
+        ]
+    )
+    assert_eq(
+        "partition with nodes opens window",
+        results[0]["active_faults"],
+        {"network_partition": {"vtime": 1.0}},
+    )
+    assert_eq(
+        "missing nodes is noop", results[1]["active_faults"], {"network_partition": {"vtime": 1.0}}
+    )
+
+    # Event without moment gets active_faults but no vtime_seconds
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            {"output_text": "no moment here"},
+        ]
+    )
+    assert_eq(
+        "event without moment still gets active_faults",
+        results[1]["active_faults"],
+        {"network_partition": {"vtime": 1.0}},
+    )
+    assert_eq("event without moment has no vtime_seconds", "vtime_seconds" not in results[1], True)
+
+    # Untracked faults (kill, stop without type/affected_nodes) produce empty active_faults
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "kill"}),
+            _evt(1.0, fault={"name": "stop"}),
+        ]
+    )
+    assert_eq("kill not tracked", results[0]["active_faults"], {})
+    assert_eq("stop not tracked", results[1]["active_faults"], {})
+
+    # Restore after only untracked faults is no-op
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "kill"}),
+            _evt(2.0, fault={"name": "restore", "type": "network", "affected_nodes": ["ALL"]}),
+        ]
+    )
+    assert_eq("restore after kill is empty", results[1]["active_faults"], {})
+
+    # =====================================================================
+    # active_faults: natural expiration
+    # =====================================================================
+    print()
+    print("active_faults: natural expiration")
+
+    # Partition expires via max_duration
+    results = process_events(
+        [
+            _evt(
+                5.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 10,
+                },
+            ),
+            _evt(10.0, output_text="mid-window"),
+            _evt(16.0, output_text="after expiry"),
+        ]
+    )
+    assert_eq(
+        "partition active mid-window",
+        results[1]["active_faults"],
+        {"network_partition": {"vtime": 5.0}},
+    )
+    assert_eq("partition expired after max_duration", results[2]["active_faults"], {})
+
+    # Clog expires via max_duration
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "clog",
+                    "type": "network",
+                    "affected_nodes": ["A"],
+                    "max_duration": 3,
+                },
+            ),
+            _evt(5.0, output_text="after"),
+        ]
+    )
+    assert_eq("clog expired", results[1]["active_faults"], {})
+
+    # Partition without max_duration never expires
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "partition", "type": "network", "affected_nodes": ["ALL"]}),
+            _evt(1000.0, output_text="much later"),
+        ]
+    )
+    assert_eq(
+        "no max_duration never expires",
+        results[1]["active_faults"],
+        {"network_partition": {"vtime": 1.0}},
+    )
+
+    # Expiration at exact boundary (end is exclusive: end_vtime <= vtime expires)
+    results = process_events(
+        [
+            _evt(
+                5.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 5,
+                },
+            ),
+            _evt(10.0, output_text="at exact end"),
+        ]
+    )
+    assert_eq("expired at exact boundary", results[1]["active_faults"], {})
+
+    # Restore before natural expiration
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(5.0, fault={"name": "restore", "type": "network", "affected_nodes": ["ALL"]}),
+        ]
+    )
+    assert_eq("restore before expiration clears", results[1]["active_faults"], {})
+
+    # =====================================================================
+    # active_faults: network fault overlap (outer boundary tracking)
+    # =====================================================================
+    print()
+    print("active_faults: network fault overlap")
+
+    # Inner fault completely covered by outer — ignored
+    results = process_events(
+        [
+            _evt(
+                5.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 20,
+                },
+            ),
+            _evt(
+                10.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 5,
+                },
+            ),
+            _evt(16.0, output_text="after inner would expire"),
+            _evt(26.0, output_text="after outer expires"),
+        ]
+    )
+    assert_eq(
+        "inner partition ignored, outer still active",
+        results[2]["active_faults"],
+        {"network_partition": {"vtime": 5.0}},
+    )
+    assert_eq("outer partition expires at 25", results[3]["active_faults"], {})
+
+    # Extending fault: A=[10,15], B=[14,19] → window=[10,19]
+    results = process_events(
+        [
+            _evt(
+                10.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 5,
+                },
+            ),
+            _evt(
+                14.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 5,
+                },
+            ),
+            _evt(16.0, output_text="after original end, before extended end"),
+            _evt(20.0, output_text="after extended end"),
+        ]
+    )
+    assert_eq(
+        "extending fault keeps original vtime",
+        results[1]["active_faults"],
+        {"network_partition": {"vtime": 10.0}},
+    )
+    assert_eq(
+        "still active after original end (extended)",
+        results[2]["active_faults"],
+        {"network_partition": {"vtime": 10.0}},
+    )
+    assert_eq("expired after extended end", results[3]["active_faults"], {})
+
+    # Non-overlapping: first expires, then new one starts
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 3,
+                },
+            ),
+            _evt(
+                5.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 3,
+                },
+            ),
+        ]
+    )
+    assert_eq("first partition", results[0]["active_faults"], {"network_partition": {"vtime": 1.0}})
+    assert_eq(
+        "new partition after first expired",
+        results[1]["active_faults"],
+        {"network_partition": {"vtime": 5.0}},
+    )
+
+    # Partition and clog overlap independently
+    results = process_events(
+        [
+            _evt(
+                5.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 20,
+                },
+            ),
+            _evt(
+                10.0,
+                fault={
+                    "name": "clog",
+                    "type": "network",
+                    "affected_nodes": ["A"],
+                    "max_duration": 3,
+                },
+            ),
+            _evt(14.0, output_text="clog expired, partition still active"),
+        ]
+    )
+    assert_eq(
+        "clog expired but partition still active",
+        results[2]["active_faults"],
+        {"network_partition": {"vtime": 5.0}},
+    )
+
+    # Extending with None (no max_duration) absorbs finite end
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 5,
+                },
+            ),
+            _evt(3.0, fault={"name": "partition", "type": "network", "affected_nodes": ["ALL"]}),
+            _evt(100.0, output_text="much later"),
+        ]
+    )
+    assert_eq(
+        "None extends finite, keeps original vtime",
+        results[1]["active_faults"],
+        {"network_partition": {"vtime": 1.0}},
+    )
+    assert_eq(
+        "never expires after None extension",
+        results[2]["active_faults"],
+        {"network_partition": {"vtime": 1.0}},
+    )
+
+    # Existing None absorbs any new fault
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "partition", "type": "network", "affected_nodes": ["ALL"]}),
+            _evt(
+                3.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(5.0, output_text="check"),
+        ]
+    )
+    assert_eq(
+        "None absorbs new finite fault",
+        results[2]["active_faults"],
+        {"network_partition": {"vtime": 1.0}},
+    )
+
+    # =====================================================================
+    # active_faults: pause clears non-clock faults
+    # =====================================================================
+    print()
+    print("active_faults: pause behavior")
+
+    # Pause clears network and node windows
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(
+                2.0,
+                fault={
+                    "name": "pause",
+                    "type": "node",
+                    "affected_nodes": ["A"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(3.0, info={"message": "status", "details": {"paused": True}}),
+        ]
+    )
+    assert_eq(
+        "network + node active before pause",
+        results[1]["active_faults"],
+        {"network_partition": {"vtime": 1.0}, "node_pause": {"A": 2.0}},
+    )
+    assert_eq("pause clears network + node", results[2]["active_faults"], {})
+
+    # Pause preserves clock windows
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "skip", "type": "clock", "details": {"offset": 10.0}}),
+            _evt(
+                2.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(3.0, info={"message": "status", "details": {"paused": True}}),
+        ]
+    )
+    assert_eq(
+        "clock survives pause",
+        results[2]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 10.0, "vtime": 1.0}},
+    )
+
+    # =====================================================================
+    # active_faults: node pause/throttle
+    # =====================================================================
+    print()
+    print("active_faults: node pause/throttle")
+
+    # Node pause opens a window
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "pause",
+                    "type": "node",
+                    "affected_nodes": ["A"],
+                    "max_duration": 10,
+                },
+            ),
+        ]
+    )
+    assert_eq("node pause opens window", results[0]["active_faults"], {"node_pause": {"A": 1.0}})
+
+    # Node throttle opens a window
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "throttle",
+                    "type": "node",
+                    "affected_nodes": ["B"],
+                    "max_duration": 10,
+                },
+            ),
+        ]
+    )
+    assert_eq(
+        "node throttle opens window", results[0]["active_faults"], {"node_throttle": {"B": 1.0}}
+    )
+
+    # Multiple containers paused simultaneously
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "pause",
+                    "type": "node",
+                    "affected_nodes": ["A"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(
+                2.0,
+                fault={
+                    "name": "pause",
+                    "type": "node",
+                    "affected_nodes": ["B"],
+                    "max_duration": 100,
+                },
+            ),
+        ]
+    )
+    assert_eq(
+        "multiple containers paused",
+        results[1]["active_faults"],
+        {"node_pause": {"A": 1.0, "B": 2.0}},
+    )
+
+    # Mixed pause and throttle on different containers
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "pause",
+                    "type": "node",
+                    "affected_nodes": ["A"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(
+                2.0,
+                fault={
+                    "name": "throttle",
+                    "type": "node",
+                    "affected_nodes": ["B"],
+                    "max_duration": 100,
+                },
+            ),
+        ]
+    )
+    assert_eq(
+        "mixed pause and throttle",
+        results[1]["active_faults"],
+        {"node_pause": {"A": 1.0}, "node_throttle": {"B": 2.0}},
+    )
+
+    # Node pause expires via max_duration
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={"name": "pause", "type": "node", "affected_nodes": ["A"], "max_duration": 5},
+            ),
+            _evt(3.0, output_text="mid"),
+            _evt(7.0, output_text="after"),
+        ]
+    )
+    assert_eq(
+        "node pause active mid-window", results[1]["active_faults"], {"node_pause": {"A": 1.0}}
+    )
+    assert_eq("node pause expired", results[2]["active_faults"], {})
+
+    # Restore does NOT affect node windows
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(
+                2.0,
+                fault={
+                    "name": "pause",
+                    "type": "node",
+                    "affected_nodes": ["A"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(3.0, fault={"name": "restore", "type": "network", "affected_nodes": ["ALL"]}),
+        ]
+    )
+    assert_eq("restore keeps node_pause", results[2]["active_faults"], {"node_pause": {"A": 2.0}})
+
+    # Node pause with empty affected_nodes is ignored
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "pause", "type": "node", "affected_nodes": []}),
+        ]
+    )
+    assert_eq("node pause empty nodes ignored", results[0]["active_faults"], {})
+
+    # Node pause without type is ignored
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "pause", "affected_nodes": ["A"], "max_duration": 10}),
+        ]
+    )
+    assert_eq("node pause without type ignored", results[0]["active_faults"], {})
+
+    # Node kill/stop are not tracked
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={"name": "kill", "type": "node", "affected_nodes": ["A"], "max_duration": 10},
+            ),
+            _evt(
+                2.0,
+                fault={"name": "stop", "type": "node", "affected_nodes": ["B"], "max_duration": 10},
+            ),
+        ]
+    )
+    assert_eq("kill not tracked", results[0]["active_faults"], {})
+    assert_eq("stop not tracked", results[1]["active_faults"], {})
+
+    # =====================================================================
+    # active_faults: clock skip (permanent)
+    # =====================================================================
+    print()
+    print("active_faults: clock skip (permanent)")
+
+    # Permanent clock skip tracked
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "skip", "type": "clock", "details": {"offset": 10.0}}),
+        ]
+    )
+    assert_eq(
+        "permanent clock skip tracked",
+        results[0]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 10.0, "vtime": 1.0}},
+    )
+
+    # Cumulative offsets
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "skip", "type": "clock", "details": {"offset": 10.0}}),
+            _evt(2.0, fault={"name": "skip", "type": "clock", "details": {"offset": 5.0}}),
+        ]
+    )
+    assert_eq(
+        "cumulative offsets",
+        results[1]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 15.0, "vtime": 2.0}},
+    )
+
+    # Negative offset reduces cumulative
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "skip", "type": "clock", "details": {"offset": 10.0}}),
+            _evt(2.0, fault={"name": "skip", "type": "clock", "details": {"offset": -10.0}}),
+        ]
+    )
+    assert_eq("offsets cancel out, entry removed", results[1]["active_faults"], {})
+
+    # Clock skip with zero offset is ignored
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "skip", "type": "clock", "details": {"offset": 0.0}}),
+        ]
+    )
+    assert_eq("zero offset ignored", results[0]["active_faults"], {})
+
+    # Clock skip without details is ignored
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "skip", "type": "clock"}),
+        ]
+    )
+    assert_eq("skip without details ignored", results[0]["active_faults"], {})
+
+    # Clock skip survives pause
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "skip", "type": "clock", "details": {"offset": 10.0}}),
+            _evt(2.0, info={"message": "status", "details": {"paused": True}}),
+            _evt(3.0, output_text="after pause"),
+        ]
+    )
+    assert_eq(
+        "clock skip survives pause",
+        results[2]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 10.0, "vtime": 1.0}},
+    )
+
+    # =====================================================================
+    # active_faults: clock skip (jitter)
+    # =====================================================================
+    print()
+    print("active_faults: clock skip (jitter)")
+
+    # Jitter tracked with duration
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "skip",
+                    "type": "clock",
+                    "max_duration": 10,
+                    "details": {"offset": 30.0},
+                },
+            ),
+        ]
+    )
+    assert_eq(
+        "jitter tracked",
+        results[0]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 30.0, "vtime": 1.0}},
+    )
+
+    # Jitter expires and offset drops out
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "skip",
+                    "type": "clock",
+                    "max_duration": 5,
+                    "details": {"offset": 30.0},
+                },
+            ),
+            _evt(3.0, output_text="mid"),
+            _evt(7.0, output_text="after"),
+        ]
+    )
+    assert_eq(
+        "jitter active mid-window",
+        results[1]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 30.0, "vtime": 1.0}},
+    )
+    assert_eq("jitter expired", results[2]["active_faults"], {})
+
+    # Jitter + permanent coexist
+    results = process_events(
+        [
+            _evt(1.0, fault={"name": "skip", "type": "clock", "details": {"offset": 10.0}}),
+            _evt(
+                2.0,
+                fault={
+                    "name": "skip",
+                    "type": "clock",
+                    "max_duration": 5,
+                    "details": {"offset": 30.0},
+                },
+            ),
+            _evt(4.0, output_text="both active"),
+            _evt(8.0, output_text="jitter expired"),
+        ]
+    )
+    assert_eq(
+        "permanent + jitter cumulative",
+        results[2]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 40.0, "vtime": 2.0}},
+    )
+    assert_eq(
+        "after jitter expires, permanent remains",
+        results[3]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 10.0, "vtime": 1.0}},
+    )
+
+    # Jitter survives pause
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "skip",
+                    "type": "clock",
+                    "max_duration": 100,
+                    "details": {"offset": 20.0},
+                },
+            ),
+            _evt(2.0, info={"message": "status", "details": {"paused": True}}),
+            _evt(3.0, output_text="after pause"),
+        ]
+    )
+    assert_eq(
+        "jitter survives pause",
+        results[2]["active_faults"],
+        {"clock_skip": {"cumulative_offset": 20.0, "vtime": 1.0}},
+    )
+
+    # =====================================================================
+    # active_faults: combined scenario (spec walkthrough)
+    # =====================================================================
+    print()
+    print("active_faults: combined scenario")
+
+    results = process_events(
+        [
+            _evt(0.0, info={"message": "status", "details": {"started": True}}),
+            _evt(
+                5.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 10,
+                    "details": {
+                        "disruption_type": "Stopped",
+                        "asymmetric": False,
+                        "partitions": [["A", "B"], ["C"]],
+                    },
+                },
+            ),
+            _evt(
+                8.0,
+                fault={
+                    "name": "clog",
+                    "type": "network",
+                    "affected_nodes": ["A"],
+                    "max_duration": 6,
+                    "details": {"disruption_type": "Stopped"},
+                },
+            ),
+            _evt(10.0, output_text="check at t=10"),
+            _evt(
+                12.0,
+                fault={"name": "pause", "type": "node", "affected_nodes": ["C"], "max_duration": 8},
+            ),
+            _evt(16.0, output_text="check at t=16"),
+            _evt(17.0, fault={"name": "restore", "type": "network", "affected_nodes": ["ALL"]}),
+            _evt(17.0, info={"message": "status", "details": {"paused": True}}),
+        ]
+    )
+    assert_eq(
+        "t=10: partition + clog active",
+        results[3]["active_faults"],
+        {"network_partition": {"vtime": 5.0}, "network_clog": {"vtime": 8.0}},
+    )
+    assert_eq(
+        "t=16: only node_pause (network expired)",
+        results[5]["active_faults"],
+        {"node_pause": {"C": 12.0}},
+    )
+    assert_eq("t=17: pause clears everything", results[7]["active_faults"], {})
+
+    # Mixed: network + node + clock
+    results = process_events(
+        [
+            _evt(
+                1.0,
+                fault={
+                    "name": "partition",
+                    "type": "network",
+                    "affected_nodes": ["ALL"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(
+                2.0,
+                fault={
+                    "name": "pause",
+                    "type": "node",
+                    "affected_nodes": ["A"],
+                    "max_duration": 100,
+                },
+            ),
+            _evt(3.0, fault={"name": "skip", "type": "clock", "details": {"offset": 5.0}}),
+            _evt(4.0, fault={"name": "restore", "type": "network", "affected_nodes": ["ALL"]}),
+        ]
+    )
+    assert_eq(
+        "all three active at t=3",
+        results[2]["active_faults"],
+        {
+            "network_partition": {"vtime": 1.0},
+            "node_pause": {"A": 2.0},
+            "clock_skip": {"cumulative_offset": 5.0, "vtime": 3.0},
+        },
+    )
+    assert_eq(
+        "restore removes only network",
+        results[3]["active_faults"],
+        {"node_pause": {"A": 2.0}, "clock_skip": {"cumulative_offset": 5.0, "vtime": 3.0}},
+    )
+
+    print()
+    if failures:
+        print(f"{failures} test(s) failed")
+        sys.exit(1)
+    else:
+        print("all tests passed")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.claude/skills/antithesis-debug/references/actions.md b/.claude/skills/antithesis-debug/references/actions.md
new file mode 100644
index 0000000..199189f
--- /dev/null
+++ b/.claude/skills/antithesis-debug/references/actions.md
@@ -0,0 +1,153 @@
+# Authorizing & Reading Actions
+
+Action cells render custom `a-button.action_auth` elements, not normal HTML
+buttons. Clicking the visible generic element is unreliable. The runtime
+provides methods that trigger the actual action button in the DOM.
+
+## Action cell anatomy
+
+Each action cell contains:
+- `a-button.action_auth` — the authorize button (disabled after authorization)
+- `.action_status_label` — status text after execution (e.g., "EXIT CODE 0", "RUNNING: ...", "Unknown start Moment")
+- `.sequence_printer_wrapper` — output container with the same widget as the logs page
+- `.sequence_toolbar__items-counter` — item count (e.g., "3 items")
+- `.event` elements — individual output rows
+
+An action is **authorized** when the button has the `disabled` attribute.
+The runtime considers an action **completed** when it is authorized AND
+the status label is present AND does not start with "RUNNING".
+
+> **Known bug — `actionCompleted` / completion detection is unreliable.**
+> The runtime's predicate misses real completions: cells that visibly
+> show "DONE" plus full output frequently come back as
+> `actionCompleted: false, statusLabel: null`. The page can render
+> results without setting `.action_status_label`, or it uses status text
+> the runtime's "doesn't start with RUNNING" check incorrectly treats as
+> in-flight.
+>
+> **Treat `notebook.getCells()[i].text` as the source of truth** for
+> whether a cell has output. If `text` includes "DONE" and the rendered
+> result, the action is done — regardless of what `actionCompleted` says.
+> For long results, read the full DOM `innerText` of the cell (the
+> runtime truncates `text` at 500 chars).
+>
+> Likewise, `waitForResult(...)` can return a false-positive completion
+> almost immediately — its first poll may hit a transient status label
+> like `"RUN BASH COMMAND: PODMAN PS -A --FORMAT=JSON | JQ -C"` which
+> doesn't start with the literal "RUNNING" and is therefore treated as
+> done, with `eventCount: 0`. If `waitForResult` returns `ok:true` but
+> with an empty `events` array and a `RUN BASH COMMAND:` status, the
+> action is actually still running. Poll `notebook.getCells()` until the
+> target cell's `text` contains "DONE".
+
+## Listing action cells
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.actions.getAll()"
+```
+
+Returns an array of `{ cellIndex, buttonText, authorized, completed, statusLabel, visible }`.
+
+## Authorizing a specific action
+
+By cell index:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.actions.authorizeByIndex(5)"
+```
+
+By matching text content in the cell:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.actions.authorizeByContent('pwd && ls -la /')"
+```
+
+Both return `{ clicked: true/false }`.
+
+## Authorizing all actions
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.actions.authorizeAll()"
+```
+
+Clicks every un-authorized action button sequentially with a 500ms pause
+between each. Returns `{ authorized, totalCells }`.
+
+## Reading a result (non-polling)
+
+After authorizing an action, check if the result is available:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.actions.getResult('pwd && ls -la /')"
+```
+
+Returns `{ ok, completed, statusLabel, itemCount, eventCount, events }` if
+completed, or `{ ok: false, completed: false, authorized }` if still running.
+
+## Waiting for a result (polling)
+
+Poll until the action completes:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.actions.waitForResult('pwd && ls -la /')"
+```
+
+Polls for up to 30 seconds (default). Returns
+`{ ok, statusLabel, itemCount, eventCount, events, attempts, waitedMs }` on
+completion, or `{ ok: false, error, authorized }` on timeout.
+
+Pass options for a different timeout:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.actions.waitForResult('pwd && ls -la /', { timeoutMs: 60000 })"
+```
+
+**Important:** `waitForResult` runs inside a single `eval` call. Keep
+`timeoutMs` under the agent-browser CDP timeout (usually ~30 seconds) to avoid
+a CDP timeout error. For longer waits, call `getResult` in a loop from the
+shell side.
+
+## Full authorize-and-read pattern
+
+1. Inject a debug cell (see `references/notebook.md`)
+2. Wait briefly for notebook recalculation (~3 seconds, or take a snapshot)
+3. Authorize the action:
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     "window.__antithesisDebug.actions.authorizeByContent('pwd && ls -la /')"
+   ```
+4. Wait for the result:
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     "window.__antithesisDebug.actions.waitForResult('pwd && ls -la /', { timeoutMs: 20000 })"
+   ```
+5. If the result is truncated or you need more detail, re-read the cell:
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     "window.__antithesisDebug.actions.getResult('pwd && ls -la /')"
+   ```
+
+## Direct DOM fallback
+
+If the runtime methods do not find the expected elements, you can fall back to
+direct DOM queries:
+
+```bash
+agent-browser --session "$SESSION" eval '(() => {
+  const cell = [...document.querySelectorAll(".cell")]
+    .find(e => (e.innerText || "").includes("pwd && ls -la /"));
+  const btn = cell && cell.querySelector("a-button.action_auth");
+  if (btn) {
+    btn.click();
+    return { clicked: true };
+  }
+  return { clicked: false };
+})()'
+```
diff --git a/.claude/skills/antithesis-debug/references/advanced-debugger.md b/.claude/skills/antithesis-debug/references/advanced-debugger.md
new file mode 100644
index 0000000..c568e2e
--- /dev/null
+++ b/.claude/skills/antithesis-debug/references/advanced-debugger.md
@@ -0,0 +1,458 @@
+# Advanced Notebook Debugger
+
+The advanced mode replaces the simplified command box with a live JavaScript
+notebook backed by Monaco. Code in the notebook is reactive: editing a cell
+recomputes everything that depends on it. The notebook is the right tool when
+you need branching, time advancement, event-set filtering, fault-injector
+control, or programmatic multi-step workflows.
+
+**Read this file before doing any work in advanced mode.** The other advanced-
+mode references (`notebook.md`, `actions.md`, `common-inspections.md`) cover
+specific mechanics; this page covers the mental model and the workflow rules
+agents have to follow to avoid landing in confusing states.
+
+## Mental model
+
+The notebook exposes a small set of top-level names defined by
+`prepare_multiverse_debugging()`:
+
+- **`moment`** — the immutable point in virtual time where the bug was caught.
+- **`environment`** — entrypoint to the system: `environment.events`,
+  `environment.containers`, `environment.host`, `environment.fault_injector`,
+  `environment.session`, `environment.session.events`, `environment.session.output`,
+  `environment.extract_file`, ...
+- **`bash`** — tagged template literal. `bash\`echo hi\`` builds an action.
+- **`action()`** — wraps work so it can be authorized together (see below).
+- **`print()`** — emits a value into a visible cell. Without `print()`, a
+  return value is computed but never shown.
+- **`help(thing)`** — short blurb. Often less useful than the schema-discovery
+  trick below.
+- **`Time.seconds(n)`** — durations for `wait`, `wait_until`, `timeout`.
+
+Two derived concepts that you must internalize:
+
+- A **Moment** is a snapshot. Immutable. Identified by a vtime + the hash of
+  all inputs leading up to it.
+- A **Branch** is a mutable timeline rooted at some moment. Operations on a
+  branch advance it. `branch.end` is the moment at the current tip.
+
+Most operations take a branch and mutate it (`bash...run({branch, ...})`,
+`branch.wait(...)`, `branch.wait_until(...)`). A few take a moment and
+produce immutable values (`environment.events.up_to(moment)`,
+`environment.containers.list({moment})`).
+
+## Reactive vs. effectful — when authorize is required
+
+The notebook has two categories of operation, and the distinction is the
+whole reason for action authorization:
+
+- **Reactive (no authorize button).** Pure derivations from already-known
+  state. Update automatically when their inputs change. Examples:
+  `environment.events.up_to(branch)` — when the branch tip moves, the cell
+  recomputes. No new probe; no action.
+- **Effectful (authorize button).** Touch the real system: probe live state,
+  run a process, change settings. Each call produces an action cell that
+  must be clicked to run. Examples: `bash...run(...)`,
+  `bash...run_in_background(...)`, `environment.extract_file(...)`,
+  `environment.containers.list({moment})` (because it queries podman),
+  `environment.fault_injector.pause/unpause/update_settings(...)`.
+
+Rule of thumb: if the value comes from inspecting event/log data the system
+already produced, it is reactive. If it requires a new probe of the running
+system, or has a co-effect on something outside the notebook, it is an action.
+
+## Switching to advanced mode
+
+There are two distinct things both called "mode":
+
+- **Page-level mode** — controlled by `window.__antithesisDebug.switchMode("advanced")`.
+  This is what `getMode()` reports. Switches the right pane between the
+  simplified command box and the notebook editor.
+- **Display tab** — the strip at the top with "Simple mode" / "Advanced mode"
+  tabs. This is a SEPARATE control over the right pane's contents, and
+  `switchMode()` does NOT click it. They can disagree (`getMode()` returns
+  `"advanced"` while the "Simple mode" tab is the active one).
+
+To enter advanced mode programmatically:
+
+```bash
+agent-browser --session "$SESSION" eval 'window.__antithesisDebug.switchMode("advanced")'
+agent-browser --session "$SESSION" eval 'window.__antithesisDebug.notebook.waitForReady()'
+```
+
+## On-page advanced-mode help — read this first
+
+There is a substantial built-in help panel (~5KB) covering the notebook
+fundamentals: cells, print, moments, branches, event sets, environment, and
+common workflows. It is hidden behind the "Advanced mode" display tab. To
+read it without scrolling the UI:
+
+```bash
+# Click the "Advanced mode" tab in the display strip
+agent-browser --session "$SESSION" eval '(() => {
+  const tabs = document.querySelectorAll(".display_area__tab");
+  for (const t of tabs) {
+    if ((t.innerText || "").trim() === "Advanced mode") { t.click(); return true; }
+  }
+  return false;
+})()'
+
+# Read its content
+agent-browser --session "$SESSION" eval 'document.querySelector(".display_area__layout").innerText'
+```
+
+This panel is more canonical than scraping `help()` cells in the notebook
+(the runtime truncates cell text at 500 chars; the panel returns the whole
+thing).
+
+## Writing notebook cells
+
+The notebook source lives in `window.editor`. The runtime exposes
+`notebook.getSource()`, `notebook.setSource(code)`, `notebook.appendSource(code)`
+— but these all reduce to `editor.setValue(...)`. Monaco recompiles on value
+change; allow **5–10 seconds** for a large source replacement to render new
+cells.
+
+Cells are separated by blank lines. Each cell is JavaScript. Important
+language quirks:
+
+- Bare assignments (`x = 1`) create notebook globals visible to other cells.
+- `var`/`let`/`const` declarations remain local to their cell.
+- `print(expr)` emits a visible cell. `print(x = 1+2)` both assigns and
+  displays.
+
+To make a result visible, **wrap every `bash...run(...)` and every value you
+want to see in `print()`**. Pushing results into an aggregate array hides
+the individual outputs; print each one separately for readable cells.
+
+For complex multi-line code, prefer writing it to a file and stdin-piping:
+
+```bash
+cat > /tmp/cell.js <<'EOF'
+(() => {
+  const e = window.editor;
+  e.setValue(e.getValue() + `
+my_action = new action({description: "demo"})
+my_branch = moment.rewind_to(15.0).branch()
+print(bash\`date\`.run({branch: my_branch, container: "CONTAINER", required_by: [my_action]}))
+`);
+  return { len: e.getValue().length };
+})()
+EOF
+cat /tmp/cell.js | agent-browser --session "$SESSION" eval --stdin
+```
+
+## Actions, authorization, and grouping
+
+Each effectful call materializes an action cell with an authorize button.
+By default, **only the first un-authorized action's cell renders its button**.
+Adding five `print(bash...run(...))` calls in source does not produce five
+buttons up front — subsequent ones appear after the prior completes.
+
+To group multiple effects under one authorize click, use `action()` to make
+a parent and link children with `required_by`:
+
+```javascript
+parent = new action({ description: "sweep", tethered_authorization: true });
+
+print(
+  bash`date`.run({
+    branch: my_branch,
+    container: "CONTAINER",
+    required_by: [parent],
+  }),
+);
+
+print(
+  bash`ls -la /`.run({
+    branch: my_branch,
+    container: "CONTAINER",
+    required_by: [parent],
+  }),
+);
+```
+
+Action props you'll use:
+
+- `description: string` — button label (defaults to the underlying op name).
+- `required_by?: Action[]` — mark THIS action as a child of another.
+- `tethered_authorization?: boolean` — children run when this parent has
+  been authorized. Set this on the parent.
+
+If you ever want to see the full list, pass a garbage opts
+object — the notebook prints a `⚠ action(...) expects: ...` warning
+listing every prop (see "schema-discovery" below).
+
+### The schema-discovery trick ("expect" pattern)
+
+Any time you don't know what arguments a function accepts, **call it with a
+deliberately wrong well-formed object**. The notebook will print a
+`⚠ functionName(...) expects: ...` cell listing the expected props. This is
+how `update_settings`, `action`, `bash.run`, `wait`, `wait_until`, etc., all
+self-document. Don't waste time on `help(foo)` for callables — pass garbage
+and read the warning.
+
+## Moments and branches
+
+```javascript
+// Make a new moment at an absolute vtime (rewinds backward from `moment`)
+m = moment.rewind_to(15.0);
+
+// Relative rewind (n seconds earlier than the current moment)
+m2 = moment.rewind(Time.seconds(3));
+
+// Create a mutable branch rooted at a moment
+b = m.branch();
+b.end; // the moment at the current branch tip
+
+// Advance time on the branch without running a command
+b.wait({ duration: Time.seconds(5), required_by: [parent] });
+
+// Advance until a matching event occurs (or until 1800s elapse — default cap)
+b.wait_until({
+  until: environment.session.output.contains({ source: "fault_injector" }),
+  required_by: [parent],
+});
+```
+
+A few things to internalize:
+
+- **Commands advance the branch.** Each `bash...run({branch, ...})` extends
+  the branch's timeline by the duration the command actually takes —
+  empirically ~50–80 ms per call for a typical short shell command (an
+  `fdbcli getrange` + a few `grep`s). Subsequent commands run from the
+  new tip. To inspect state at exactly vtime X, rewind to X − 0.005 or
+  so, so the actual probe-execution time lands near (not past) X.
+- **Without a wait, the SUT is frozen.** Between your commands, no
+  simulated time passes. `wait` and `wait_until` are how you let the SUT
+  run for a duration so background activity (fault injector, timers,
+  scheduled work) can happen.
+- **A nonzero exit code terminates the branch.** Any subsequent command on
+  the same branch produces
+  `CAMPAIGN SAW TERMINAL EVENT: 'RUN BASH COMMAND: ...' EXITED WITH
+NONZERO EXIT CODE N'`. You cannot "undo" the termination — make a fresh
+  branch from the same (or an earlier) moment.
+
+  **`grep` is the most common landmine.** `grep PATTERN file` returns exit
+  1 on no match, which terminates the branch even though the probe
+  "worked." Wrap any terminal `grep` (or `find`, or anything else that
+  may legitimately return nonzero) with `|| true` or `|| echo
+not-found` so the script always exits 0:
+
+  ```bash
+  grep 'sm_repeater_fanout' /tmp/sm24.txt | head -1 || echo NO_FANOUT
+  ```
+
+### Time travel — sweeping multiple moments
+
+```javascript
+sweep_action = new action({
+  description: "sweep",
+  tethered_authorization: true,
+});
+for (vt of [15, 18, 21, 24, 27, 30]) {
+  b = moment.rewind_to(vt).branch();
+  print(
+    bash`date && ps -elf`.run({
+      branch: b,
+      container: "CONTAINER",
+      required_by: [sweep_action],
+    }),
+  );
+}
+```
+
+If you mass-branch like this and get `CAMPAIGN SAW TERMINAL EVENT: 'FUZZER
+REJECTED CAMPAIGN ADD WITH CODE: DUPLICATE_ID, STATUS: 400'`, the campaign
+fuzzer is rejecting too many sibling branches off the same moment. Work
+around by spacing the rewinds slightly (different moments per child) or by
+doing the sweep on a single branch with `wait` in between.
+
+## Running commands on the host
+
+`environment.host` is the special container reference for the host. It is
+what the simplified debugger labels `(host)` in the dropdown.
+
+```javascript
+print(
+  bash`ls /opt`.run({
+    branch: my_branch,
+    container: environment.host,
+    required_by: [parent],
+  }),
+);
+```
+
+## Exploring alternate histories with `send_input`
+
+From the same moment, a `wait()` always produces the same history (the
+simulator is deterministic). To explore _different_ random histories from
+the same moment — e.g. to try to recreate a specific event after making a
+change, or to compare what could have happened — use `send_input` on the
+branch instead of (or before) `wait`.
+
+```js
+branch.send_input({ input_bytes: [1, 2, 3, 4], required_by: [parent] });
+```
+
+Each value in `input_bytes` is a byte that roughly corresponds to one
+second of history (not a hard-and-fast rule) and tweaks the random number
+seed as it is consumed.
+
+Choose the bytes at random. A typical recipe to enter a fresh history is:
+`send_input` a few bytes, then `wait` for the duration you actually want
+to observe. Each different combination of bytes produces a unique
+history.
+
+## Background processes and process events
+
+```javascript
+print(
+  (p = bash`sleep 3 && echo done`.run_in_background({
+    branch: b,
+    container: "CONTAINER",
+    required_by: [parent],
+  })),
+);
+
+// Without doing anything, `p` sits frozen — the SUT does not advance.
+// Use a wait or wait_until to let it run.
+b.wait_until({ until: p.exits, required_by: [parent] });
+```
+
+The process object `p` exposes event sets like `p.exits` (a `FlatMapEventSet`
+that matches the specific process's exit event). The rule is the same:
+without an explicit advance, the backgrounded work makes no progress.
+
+## Event sets
+
+`environment.events` and `environment.session.output` are predefined event
+sets. They are "lenses" — abstract queries you compose by filtering, then
+evaluate by calling `up_to(moment_or_branch)`:
+
+```javascript
+// All output events where source is "fault_injector", up to the branch tip
+print(
+  environment.session.output.contains({ source: "fault_injector" }).up_to(b),
+);
+```
+
+Two useful refinements:
+
+- `.contains({source: "fault_injector"})` matches the entire fault_injector
+  subsystem — actual fault injections AND metrics AND control messages
+  (pause/unpause status). To see only real faults, filter more tightly
+  (try `.contains({source: "fault_injector", "fault.name": "partition"})`
+  and use the schema-discovery trick if the filter shape is wrong).
+- Passing a branch to `up_to` makes the cell reactive to that branch's tip
+  — when commands advance the branch, the events cell recomputes
+  automatically.
+
+## Fault injector control
+
+`environment.fault_injector` provides:
+
+```javascript
+environment.fault_injector.pause({ branch: b, required_by: [parent] });
+environment.fault_injector.unpause({ branch: b, required_by: [parent] });
+environment.fault_injector.update_settings({
+  parameters: {
+    /* ... */
+  },
+  branch: b,
+  settings: {
+    /* ... */
+  }, // optional
+  required_by: [parent],
+});
+```
+
+Under the hood, `pause` and `unpause` schedule a backgrounded
+`fault_injector_update --pause --description=...` command on the host; they
+show up as a nested `Run bash command in background:` cell. The
+`update_settings` signature is best discovered via the schema-discovery
+trick — pass `{}` and read the warning.
+
+A paused branch will still emit fault_injector METRICS and guest-state events
+during a `wait`. Real fault injections (events with payload
+`{fault:{...name:partition...}}`) DO stop. If you look at a paused window
+and see fault_injector entries anyway, check the event payload.
+
+## Workflow rules
+
+These are project-team conventions, not just suggestions:
+
+1. **Don't edit a command that has already been authorized.** If a command
+   needs to change after authorize, either make a new branch+action for
+   the new attempt, or use a fresh action. Editing the source of an
+   already-authorized cell will cause it to re-run without re-authorization
+   (a known bug). Correcting typos in not-yet-authorized cells is fine.
+2. **Each `bash...run(...)` should be wrapped in `print()`** so the result
+   appears in its own visible cell.
+3. **One nonzero exit poisons the branch.** Don't keep adding commands to
+   a terminated branch — fork a new one.
+4. **Multiple branches off the same moment are independent.** This is a
+   feature: you can pause faults on one branch, leave them active on
+   another, and compare.
+5. **Don't trust `actionCompleted` from the runtime.** It under-reports
+   completion (returns `false` while the cell text clearly shows "DONE").
+   Read the cell's text instead (`notebook.getCells()` truncates at 500
+   chars — fall back to DOM `cell.innerText` for full content).
+
+## Reading results
+
+```bash
+# Snapshot of all cells (text truncated at 500 chars per cell)
+agent-browser --session "$SESSION" eval 'window.__antithesisDebug.notebook.getCells()'
+
+# Full text of a specific cell from the DOM (no truncation)
+agent-browser --session "$SESSION" eval '(() => {
+  const cells = document.querySelectorAll(".cell");
+  return (cells[INDEX].innerText || "").trim();
+})()'
+
+# List of action cells with their auth/completion state
+agent-browser --session "$SESSION" eval 'window.__antithesisDebug.actions.getAll()'
+```
+
+When a `bash...run(...)` cell is DONE, its output is rendered as a sequence
+of event lines, each prefixed with the simulated vtime at which the line
+was emitted:
+
+```
+Run bash command: date
+DONE
+1 item
+16.023  Wed May 13 16:19:12 UTC 2026
+```
+
+The vtime on the output line tells you when the command actually executed
+on the branch. Wall-clock `date` correlates 1:1 with simulated vtime — the
+simulator's clock advances in lock-step.
+
+## When the page errors out
+
+If the browser tab hits `chrome-error://chromewebdata/` or `document.title`
+no longer matches the debugger, the runtime is gone. Reopen the URL in the
+same `--session`:
+
+```bash
+agent-browser --session "$SESSION" open "$URL"
+agent-browser --session "$SESSION" wait --load networkidle
+cat assets/antithesis-debug.js | agent-browser --session "$SESSION" eval --stdin
+agent-browser --session "$SESSION" eval 'window.__antithesisDebug.notebook.waitForReady()'
+```
+
+Notebook source and authorized-action history have, empirically, survived
+crash+reload (probably persisted server-side by the debug session URL).
+Don't rely on this — always be prepared to re-inject any custom cells.
+
+## Common error messages
+
+| Status / message                                                                                   | What it means                                                                                                                     | What to do                                                                                        |
+| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| `CAMPAIGN SAW TERMINAL EVENT: '... EXITED WITH NONZERO EXIT CODE N'`                               | A command on this branch failed                                                                                                   | Make a new branch; the old one is terminated                                                      |
+| `CAMPAIGN SAW TERMINAL EVENT: 'FUZZER REJECTED CAMPAIGN ADD WITH CODE: DUPLICATE_ID, STATUS: 400'` | Too many sibling branches off the same moment                                                                                     | Vary the moments (different `rewind_to` values) or fold into one branch                           |
+| `UNKNOWN START MOMENT`                                                                             | An action references a moment/branch/event set that hasn't been materialized (e.g., `wait_until(p.exits)` when `p` was never run) | Authorize the upstream action first, or fix the dependency                                        |
+| `⚠ FOO(...) expects: ...`                                                                          | You passed FOO a wrong-shaped argument                                                                                            | Read the listed props and supply them. Use this same trick deliberately to discover unknown APIs. |
+| `Get events up to undefined`                                                                       | The branch passed to `up_to` has no end yet (no commands have run on it)                                                          | Authorize at least one effect on the branch, or use a concrete moment                             |
diff --git a/.claude/skills/antithesis-debug/references/common-inspections.md b/.claude/skills/antithesis-debug/references/common-inspections.md
new file mode 100644
index 0000000..91eae4f
--- /dev/null
+++ b/.claude/skills/antithesis-debug/references/common-inspections.md
@@ -0,0 +1,272 @@
+# Common Inspections
+
+Inject these into the notebook using
+`window.__antithesisDebug.notebook.appendSource(...)` or direct
+`window.editor.setValue(...)` calls (see `references/notebook.md`).
+
+After injecting, authorize the resulting action cell and read the output (see
+`references/actions.md`).
+
+In all snippets below, replace `CONTAINER` with the actual container name
+discovered from `environment.containers.list({moment})` or from triage report
+evidence.
+
+## Discover available containers
+
+Always run this first to find valid container names:
+
+```javascript
+print((containers = environment.containers.list({ moment })));
+```
+
+This returns an array with each container's `name`, `id`, `state`, `image`,
+and `image_id`.
+
+## Running a shell command
+
+Fork a branch from the bug moment and run a bash command in a container:
+
+```javascript
+branch = moment.branch();
+print(bash`YOUR COMMAND HERE`.run({ branch, container: "CONTAINER" }));
+```
+
+The `bash` tagged template literal supports JavaScript interpolation. The
+`.run()` call is synchronous — it advances the branch timeline until the
+command exits. Use any shell command you need; the agent can compose these
+freely.
+
+**Wrap every `bash...run(...)` in `print()`.** Without `print()` the call
+still runs, but the return value is computed silently — no per-command
+output cell appears. Pushing results into an aggregate array also hides
+individual outputs; emit each `print(bash...run(...))` separately so each
+command's output is its own readable cell.
+
+### Running on the host
+
+Use `environment.host` as the container reference for host-level commands
+(this is what the simplified debugger labels `(host)` in the dropdown):
+
+```javascript
+print(
+  bash`ls /opt`.run({
+    branch,
+    container: environment.host,
+    required_by: [parent_action],
+  }),
+);
+```
+
+## Extract a file for download
+
+```javascript
+link = environment.extract_file({
+  moment,
+  path: "/path/to/file",
+  container: "CONTAINER",
+});
+print(link);
+```
+
+## Check fault injection state
+
+```javascript
+print(environment.fault_injector.get_settings({ moment })?.faults_paused);
+```
+
+## View events leading up to the bug moment
+
+```javascript
+print(environment.events.up_to(moment));
+```
+
+## Peek into the future
+
+Branch from the moment and advance time to see what happens next:
+
+```javascript
+branch = moment.branch();
+branch.wait(Time.seconds(5));
+print(environment.events.up_to(branch));
+```
+
+## Run a command with a timeout
+
+```javascript
+branch = moment.branch();
+print(
+  bash`COMMAND`.run({
+    branch,
+    container: "CONTAINER",
+    timeout: Time.seconds(10),
+  }),
+);
+```
+
+## Run a command in the background
+
+```javascript
+branch = moment.branch();
+print(
+  bash`COMMAND`.run_in_background({
+    branch,
+    container: "CONTAINER",
+  }),
+);
+branch.wait(Time.seconds(10));
+```
+
+Background execution advances the branch only until command delivery, not
+completion.
+
+## Probe with a base64-packaged script
+
+When a probe needs raw-byte escape sequences (FDB key prefixes, binary
+patterns) or complex nested quoting, composing the script directly through
+JS string escaping → bash → printf is fragile and easy to get wrong (a
+common failure mode: `printf` interpolating `\x02` into the actual byte
+0x02 before the inner tool can interpret it).
+
+Workaround: write the script as a literal heredoc locally, base64-encode
+it, and send `echo  | base64 -d | bash`. Single layer of JS escaping
+handles base64 cleanly.
+
+**Simplified mode** — pack the whole probe (fdbcli + grep + summarize)
+into one base64-d command so `/tmp` lifetime isn't an issue:
+
+```bash
+SCRIPT=$(cat <<'EOF'
+(printf 'getrange "\\x02sm_24\\x00" "\\x02sm_24\\xff" 2000\nexit\n') \
+  | fdbcli -C /etc/foundationdb/fdb.cluster > /tmp/sm24.txt 2>&1
+echo "===TOTAL_FANOUT==="
+grep -c 'sm_repeater_fanout' /tmp/sm24.txt
+echo "===HITS_FOR_139==="
+grep -E 'sm_repeater_fanout.*\\x15\\x8b' /tmp/sm24.txt || echo NO_MATCH
+EOF
+)
+B64=$(printf '%s' "$SCRIPT" | base64 -w0)
+
+RESP=$(agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.runCommand('echo $B64 | base64 -d | bash')")
+COUNT=$(printf '%s' "$RESP" | jq -r '.outputCountBefore')
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.waitForNewOutput($COUNT, { timeoutMs: 60000 })"
+```
+
+Note the double-backslashes inside the heredoc (`\\x02`, `\\x8b`): inside
+the single-quoted EOF, bash takes them literally as the two-char sequence
+`\x`; printf then preserves the backslash for the inner tool (`fdbcli`,
+`grep -E`) to interpret. A common mistake is using single-backslash
+(`\x02`) — printf consumes that to byte 0x02 before fdbcli sees it, and
+fdbcli reports `Command failed to completely parse`.
+
+**Advanced mode** — the same trick works inside a `bash...run`
+template literal when its escape stack gets hairy:
+
+```javascript
+const SCRIPT_B64 = "KHByaW50ZiAnZ2V0cmFuZ2UgIlxceDAyc21fMjRcXHgwMCIgIlx...";
+print(
+  bash`echo ${SCRIPT_B64} | base64 -d | bash`.run({
+    branch,
+    container: "fdb-server-1",
+    required_by: [parent],
+  }),
+);
+```
+
+## Notes
+
+- Use `environment.containers.list({moment})` to discover available container
+  names before running any commands.
+- `containers` may be empty at the exact bug `moment`. Try rewinding slightly:
+  `moment.rewind(Time.seconds(1))`.
+- Each `bash\`...\`` invocation creates an action that requires authorization
+  before it runs.
+- Default timeout for `bash...run()` is 30 virtual minutes. Pass `timeout: Time.seconds(N)` for shorter timeouts.
+
+## Time-travel sweep — probe the same container at multiple moments
+
+### Simplified-mode sweep (preferred when you only need shell output)
+
+The simplified runtime intentionally does NOT expose a `setMoment(vt)`
+helper: the page's vtime input commits via React's `focusout` handler,
+which only fires on _trusted_ (real) events. Any in-page setter would
+update the display without changing the committed moment. Drive the
+vtime input from agent-browser instead — find the input ref via a
+snapshot, then `fill` + Tab:
+
+```bash
+# One-time: find the vtime input ref
+agent-browser --session "$SESSION" snapshot -i -s "#ceres-time-input"
+# => textbox [ref=e19]: ...
+
+setMomentReal() {
+  agent-browser --session "$SESSION" click @e19
+  agent-browser --session "$SESSION" fill @e19 "$1"
+  agent-browser --session "$SESSION" keyboard type $'\t'   # real Tab — React hears focusout
+}
+
+SCRIPT='cat /path/to/file 2>&1; stat -c "%y" /path/to/file 2>&1'
+for VT in 125.0 125.7 126.5 127.5 128.0 128.5 129.0 129.5; do
+  setMomentReal "$VT"
+  sleep 2                # let React state settle BEFORE reading count
+  COUNT=$(agent-browser --session "$SESSION" eval 'window.__antithesisDebug.simplified.getOutputCount()' \
+    | python3 -c 'import sys,json; print(json.load(sys.stdin)["count"])')
+  agent-browser --session "$SESSION" eval \
+    "window.__antithesisDebug.simplified.runCommand($(printf '%s' "$SCRIPT" | jq -R -s .))"
+  agent-browser --session "$SESSION" eval \
+    "window.__antithesisDebug.simplified.waitForNewOutput($COUNT, { timeoutMs: 25000 })"
+  sleep 2                # let the post-output state settle before the next iteration
+done
+```
+
+**Settle times matter.** Without the `sleep 2` before AND after each
+iteration, the loop alternates success/failure on a 2-cycle (every other
+iteration fails with `CDP command timed out: Runtime.evaluate` or returns
+a null `header`). The fresh-vtime backend compute typically takes 9–10s
+inside the 25s `waitForNewOutput` window; previously-computed vtimes
+return in ~2s.
+
+Set the container once via `clickLogRow(N)` on a row from the desired
+container before starting the loop (driving the vtime input does not
+change the container selector). The first command after a fresh
+`clickLogRow` runs in that container; the `fill`+`Tab`-driven iterations
+that follow keep the same container.
+
+### Advanced-mode sweep (preferred when you need event sets, branching, or `wait_until`)
+
+For an absolute vtime, branch from a moment built with `rewind_to`:
+
+```javascript
+sweep_action = new action({
+  description: "sweep",
+  tethered_authorization: true,
+});
+
+for (vt of [15, 18, 21, 24, 27, 30]) {
+  b = moment.rewind_to(vt).branch();
+  print(
+    bash`date && ps -elf`.run({
+      branch: b,
+      container: "CONTAINER",
+      required_by: [sweep_action],
+    }),
+  );
+}
+```
+
+`tethered_authorization: true` on the parent makes all `required_by`
+children run from a single click on the parent's authorize button.
+Without grouping, only the first child's action_auth cell would render up
+front.
+
+**`DUPLICATE_ID` from too many sibling branches.** If a tightly-clustered
+sweep produces `CAMPAIGN SAW TERMINAL EVENT: 'FUZZER REJECTED CAMPAIGN ADD
+WITH CODE: DUPLICATE_ID, STATUS: 400'`, the campaign fuzzer is rejecting
+multiple branches off the same moment. Workarounds: space the rewinds so
+each child uses a slightly different moment, or fold the sweep onto a
+single branch with `wait` between commands.
+
+See `references/advanced-debugger.md` for the full mental model, including
+how `wait`/`wait_until` advance branches and the rules around terminated
+branches.
diff --git a/.claude/skills/antithesis-debug/references/download-log.md b/.claude/skills/antithesis-debug/references/download-log.md
new file mode 100644
index 0000000..22c6bab
--- /dev/null
+++ b/.claude/skills/antithesis-debug/references/download-log.md
@@ -0,0 +1,102 @@
+# Downloading the MVD events log
+
+When you need the full event log from a Multiverse Debugger session for
+local analysis (jq filtering, fault-window inspection, cross-referencing
+with code), download it via the `assets/download-mvd-log.sh` script. It
+drives `agent-browser`, captures the page's "Download as JSON" / "Download
+as TXT" / "Download as CSV" output, and (for JSON) post-processes the
+result through `assets/process-logs.py` to add `vtime_seconds` and
+`active_faults` annotations.
+
+## When to use
+
+- Triaging an MVD session offline — pull the log once, query it
+  repeatedly with `jq` rather than re-clicking through the browser.
+- Cross-referencing the MVD log with logs from other tools or with the
+  system-under-test source code.
+- Sharing a snapshot of the debugger session's events with a teammate.
+
+For interactive debugging (running shell commands inside containers,
+inspecting filesystem state, time-traveling), use the simplified or
+notebook workflows in this skill instead — those don't need the log
+download.
+
+## Prerequisites
+
+- `agent-browser` installed and authenticated to the tenant. If you are
+  not authenticated, run the interactive login flow first (see the
+  `antithesis-agent-browser` skill's auth setup reference).
+- A debugging-session URL (`https://TENANT.antithesis.com/debugging-session/...`).
+
+## Usage
+
+```bash
+assets/download-mvd-log.sh \
+  --url "$DEBUG_URL" \
+  -o /tmp/mvd/${SESSION_ID}.json
+```
+
+Optional flags:
+
+- `--format json|txt|csv` (default `json`). Use `txt` for quick eyeballing
+  in a pager; `json` for any analysis.
+- `--raw` — write the unmodified JSON array; skip the `process-logs.py`
+  annotation. Use this if you want the file in its on-the-wire form
+  (no `vtime_seconds`, no `active_faults`). Only meaningful with
+  `--format json`.
+
+Always write to a unique path. Other agents may be running concurrently.
+
+## What the script does
+
+1. Opens the URL with shared `--session-name antithesis` auth.
+2. Verifies the URL is on a `/debugging-session/...` path; bails (exit 2)
+   if redirected to a login page.
+3. Injects `assets/antithesis-debug.js`.
+4. Waits for `simplified.waitForReady()` (the top interaction area), then
+   polls `simplified.eventsLogReady(format)` until the events log panel
+   has rendered. The events panel renders later than the simplified
+   view's interaction area, and after page settle two `events.*` download
+   anchors exist (one hidden in a notebook overlay, one in the visible
+   Debug Timeline panel). The helper picks the visible one.
+5. Calls `simplified.prepareLogDownload(format)` to force the
+   ``'s shadow-root menu visible and tag the link.
+6. Captures the click via `agent-browser download`.
+7. For `json` (and not `--raw`): pipes through `assets/process-logs.py`
+   → JSON array annotated with `vtime_seconds` and `active_faults`.
+8. For `txt` / `csv` / `--raw`: writes the captured file verbatim.
+
+## Output format
+
+`json` output is a JSON array of events. Each event has at minimum:
+
+```json
+{
+  "moment": {
+    "_vtime_ticks": ,
+    "input_hash": ,
+    "session_id": 
+  },
+  "source": {"name": , ...},
+  ...
+}
+```
+
+Note the legacy `moment._vtime_ticks` field (integer ticks, 2³² per
+second). `process-logs.py` accepts both this and the newer
+`moment.vtime` (string seconds) format. After annotation each event also
+carries `vtime_seconds` (rounded float, for human-readable jq filters)
+and `active_faults` (snapshot of currently-open fault windows).
+
+For jq query examples, see the `antithesis-triage` skill's logs reference
+(the same event shape applies).
+
+## Known limitations
+
+- The MVD page lazy-renders the events panel after the simplified view's
+  interaction area becomes interactive. The script waits up to 30s for
+  the panel; in slow environments it may need more.
+- The downloaded log uses `_vtime_ticks` (legacy SPA format), not the
+  API's `vtime` string. To address a moment when calling
+  `snouty runs logs` against this log, convert ticks to seconds:
+  `vtime_seconds = _vtime_ticks / 4294967296`.
diff --git a/.claude/skills/antithesis-debug/references/notebook.md b/.claude/skills/antithesis-debug/references/notebook.md
new file mode 100644
index 0000000..4cf14b6
--- /dev/null
+++ b/.claude/skills/antithesis-debug/references/notebook.md
@@ -0,0 +1,188 @@
+# Notebook Interaction
+
+The debugger page is a notebook backed by a live Monaco editor, not a static
+report. Code changes trigger notebook recalculation.
+
+## Reading the notebook
+
+Get the full editor source:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.getSource()"
+```
+
+Returns `{ ok, source, length }`.
+
+Get all rendered cells with their outputs:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.getCells()"
+```
+
+Each cell includes `index`, `text`, `hasAction`, `output`, `code`, and
+`visible`.
+
+> **Cell `text` is truncated at 500 chars** by the runtime's `clean()`
+> helper. If a cell's rendered output is long (help text, an event listing,
+> a long file dump), `getCells()` will silently cut it. To read the full
+> content of a specific cell, query the DOM directly:
+>
+> ```bash
+> agent-browser --session "$SESSION" eval '(() => {
+>   const cells = document.querySelectorAll(".cell");
+>   return (cells[INDEX].innerText || "").trim();
+> })()'
+> ```
+
+## Writing to the notebook
+
+Replace the entire editor source:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.setSource('print(\"hello\")')"
+```
+
+Append code to the end of the editor:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.appendSource('\\n// new cell\\nprint(42)')"
+```
+
+Under the hood both `setSource` and `appendSource` call
+`window.editor.setValue(...)`. Monaco recompiles on value change and the
+debugger's notebook re-evaluates affected cells. **Allow 5–10 seconds for
+new cells to render** after a large source replacement; small appends
+typically settle within ~2s.
+
+For complex multi-line code, use `eval --stdin` or construct the string with a
+helper to avoid shell quoting issues:
+
+```bash
+agent-browser --session "$SESSION" eval '(() => {
+  const e = window.editor;
+  const src = e.getValue();
+  const bt = String.fromCharCode(96);
+  const tail =
+    "\n// inspect\n" +
+    "inspect_branch = moment.branch()\n" +
+    "print(bash" + bt + "pwd && ls -la /" + bt + ".run({branch: inspect_branch, container: container}))\n";
+  e.setValue(src + tail);
+  return true;
+})()'
+```
+
+## Default notebook model
+
+The seeded notebook typically contains:
+
+```javascript
+[environment, moment] = prepare_multiverse_debugging()
+print(environment.events.up_to(moment))
+print(containers = environment.containers.list({moment}))
+print(environment.fault_injector.get_settings({moment})?.faults_paused)
+container = containers[0]?.name ?? "foo"
+branch = moment.branch()
+print(bash`echo "hello" > /tmp/world && echo done`.run({branch, container}))
+```
+
+Important:
+- `containers` may be empty at the exact bug `moment` — try `moment.rewind(Time.seconds(1))`
+- The seeded notebook assigns `container = containers[0]?.name` — use that variable in your injected cells
+- Actions (shell commands via `bash\`...\``) do not run until explicitly authorized
+- Bare assignments (no `var`/`let`/`const`) create notebook globals accessible across cells
+- Variables declared with `var`/`let`/`const` remain local to their cell
+
+## Checking editor status
+
+One-shot readiness probe:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.loadingFinished()"
+```
+
+Diagnostic status if things look wrong:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.loadingStatus()"
+```
+
+Low-level window probes for debugging:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  'Object.keys(window).filter(k => /editor|notebook/i.test(k))'
+```
+
+## `action()` for batched authorization
+
+When you write multiple `bash\`...\`.run(...)` calls in the notebook source,
+**only the first action_auth cell appears** until that action completes.
+Adding 5 sequential `print(bash...run(...))` lines does not produce 5
+auth-able cells up front.
+
+To probe N moments with a single authorization click, create a parent
+`action()` and link each effect to it via `required_by` (the parent runs
+its children when authorized, thanks to `tethered_authorization: true`):
+
+```javascript
+parent = new action({ description: "sweep", tethered_authorization: true })
+
+for (m of [moment, moment.rewind(1.0), moment.rewind(2.0)]) {
+  print(bash`cat /path/to/file`.run({
+    branch: m.branch(),
+    container,
+    required_by: [parent],
+  }))
+}
+```
+
+> **Do not pass a function to `action()`** — the legacy form
+> `action(() => { ... })` is no longer accepted and prints
+> `⚠ action(...) expects: ✘ props : ✘ Optionally : ...`. The current
+> `action()` takes only an opts object. Do the work in regular cells linked
+> by `required_by`.
+
+Action props you'll use:
+
+- `description: string` — button label.
+- `required_by?: Action[]` — declare this as a child of another action.
+- `tethered_authorization?: boolean` — set on a parent so children fire
+  when the parent is authorized.
+
+The on-page schema lists more props (`display_ui`, `ready`, `when`) used for
+non-interactive notebook automation; agentic sessions don't need them. Pass
+a garbage opts object if you ever need to see the full schema — the
+notebook will print a `⚠ action(...) expects: ...` warning listing every
+prop.
+
+See `references/advanced-debugger.md` for the full grouping pattern and
+common errors (e.g., `DUPLICATE_ID` from too many sibling branches).
+
+## Troubleshooting: stuck eval after `setSource`
+
+Symptom: after overwriting the seeded notebook source with `setSource(...)`,
+every cell reports `⚠ReferenceError  is not defined!!!` and the error
+persists across page reloads.
+
+Even though `prepare_multiverse_debugging()` directly returned a valid
+`[Environment, Moment]` tuple when assigned to a single variable in the
+same source, the destructured names (`environment`, `moment`) failed to
+bind.
+
+**Workaround that cleared the stuck state**: replace the destructuring with
+separate assignments:
+
+```javascript
+notebook_version(2, true)
+_t = prepare_multiverse_debugging()
+env = _t[0]
+mom = _t[1]
+container = "CONTAINER"
+print(bash`...`.run({ branch: mom.branch(), container }))
+```
diff --git a/.claude/skills/antithesis-debug/references/setup-session.md b/.claude/skills/antithesis-debug/references/setup-session.md
new file mode 100644
index 0000000..6c3d704
--- /dev/null
+++ b/.claude/skills/antithesis-debug/references/setup-session.md
@@ -0,0 +1,177 @@
+# Setup & Session
+
+## Launching an MVD session
+
+Before opening a debugger URL, you may need to launch the session first. If the
+user already gave you a debugging-session URL, skip this section. Launching
+requires `snouty`:
+
+- DO NOT PROCEED if `snouty` is not installed. See
+  `https://raw.githubusercontent.com/antithesishq/snouty/refs/heads/main/README.md`
+  for installation options.
+- DO NOT PROCEED if `snouty` is not at least version 0.6.0. Use `snouty
+--version` to find the version. Use `snouty update` to update.
+
+`snouty debug` is the launch command. Identify the target run with exactly
+one of `--run-id` (preferred) or `--session-id`, and pin the moment to debug
+with `--input-hash` and `--vtime`:
+
+```bash
+snouty debug \
+  --run-id "$RUN_ID" \
+  --input-hash "$INPUT_HASH" \
+  --vtime "$VTIME" \
+  --description "$DESCRIPTION" \
+  --recipients "$EMAIL"
+```
+
+`--description` is optional, but always pass one and make it **unique and
+findable later** (e.g., include the property name, a date stamp, or a ticket
+id) — you'll use this string to locate the session in the list of debugging
+sessions when you (or a teammate) come back to it. `--recipients` is an
+optional semicolon-delimited list of emails to notify.
+
+### Getting the input hash and vtime
+
+`--input-hash` and `--vtime` pin the single moment to debug. Extract the input hash and vtime from context and pass them as flags. (`--input-hash` is often negative; keep the leading `-`.)
+
+Infer which moment to use based on the user's prompt. The user may ask you to get a moment from a few different sources:
+
+- A `Moment.from({ ... })` blob from the report's _copy moment_ button, a
+  copied log line, or some free-form message — read the `input_hash` and `vtime`
+  out of it yourself.
+- **The API.** Moments come back from `snouty runs show $RUN_ID` (the failure
+  moment of an incomplete run), `snouty runs properties $RUN_ID --detail`
+  (example / counterexample moments per property), and the `moment` attached
+  to every entry from `snouty runs logs` and `snouty runs events`.
+- **An instruction to go find one.** If the user says something like "debug the
+  failing `Counter` property" or "start a debugging session at an event
+  containing the error message XYZ," use the API sources above to resolve the
+  concrete input hash and vtime first.
+
+If you can't pin down both an input hash and a vtime from a clear source, ask
+the user rather than guessing.
+
+Snouty returns the debugging-session URL on success; proceed to "Opening a
+debugger URL" below.
+
+## Session naming
+
+Use a fresh, unique browser session for each debugging run so concurrent agents
+do not collide:
+
+```
+SESSION="antithesis-debug-$(date +%s)-$$"
+```
+
+Always pair with `--session-name antithesis` so `agent-browser` manages shared
+authentication state automatically.
+
+Replace `$SESSION` in all commands below.
+
+## Opening a debugger URL
+
+Open the provided URL:
+
+```bash
+agent-browser --session "$SESSION" --session-name antithesis open "$URL"
+agent-browser --session "$SESSION" wait --load networkidle
+```
+
+Then verify auth deterministically by checking the URL the browser landed on:
+
+```bash
+agent-browser --session "$SESSION" get url
+```
+
+If the URL still starts with `https://$TENANT.antithesis.com/...` you are
+authenticated and can proceed. If it redirected to `accounts.google.com`
+(or any other login domain), authentication is needed — defer to the
+`antithesis-agent-browser` skill's `references/setup-auth.md` for the
+interactive login flow. Use the same `--session-name antithesis` so auth
+state is shared.
+
+> **The `?auth=v2.public...` token in a debugger URL is NOT a session
+> token.** It scopes access to a specific report; a session cookie from a
+> full login is still required. Even a fresh PASETO token in the URL
+> won't bypass the SSO redirect on first contact. Plan to run the
+> interactive login flow once per `--session-name`.
+
+> **Auth domain note:** the `antithesis-agent-browser` `setup-auth.md`
+> directs the user to `https://antithesis.com/login/?redirect=home`. That
+> establishes auth at the central domain; for tenant subdomains
+> (`$TENANT.antithesis.com`) the cookies propagate in the same browser
+> session-name, so after login you can re-open the tenant URL headless.
+
+## Injecting the runtime
+
+After the page loads, inject the debugger runtime. This is required for **both**
+simplified and advanced modes:
+
+```bash
+cat assets/antithesis-debug.js \
+  | agent-browser --session "$SESSION" eval --stdin
+```
+
+This registers methods on `window.__antithesisDebug` with three namespaces:
+`simplified`, `notebook`, and `actions`.
+
+If `window.__antithesisDebug` is missing after a navigation or page reload,
+reinject `assets/antithesis-debug.js` and retry.
+
+## Detecting and switching modes
+
+The debugger usually opens in simplified mode, but some tenants may default to
+advanced mode. After injecting the runtime, check which mode is active:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.getMode()"
+```
+
+Returns `"simplified"` or `"advanced"`. To switch:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.switchMode("simplified")'
+```
+
+## Waiting for readiness
+
+For simplified mode:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.waitForReady()"
+```
+
+For advanced mode (notebook):
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.notebook.waitForReady()"
+```
+
+Both wait methods poll for up to 60 seconds and return a result object with
+`ok`, `ready`, `attempts`, and `waitedMs`. On timeout, the result also includes
+`details`.
+
+## Snapshot for orientation
+
+After the page is ready, take a snapshot for visual context:
+
+```bash
+agent-browser --session "$SESSION" snapshot -i -C
+```
+
+## Cleanup
+
+When debugging is complete, or if you abort after opening a browser session,
+close it explicitly:
+
+```bash
+agent-browser --session "$SESSION" close
+```
+
+Closing the live session is safe because the shared Antithesis authentication
+state is managed separately by `--session-name antithesis`.
diff --git a/.claude/skills/antithesis-debug/references/simplified-debugger.md b/.claude/skills/antithesis-debug/references/simplified-debugger.md
new file mode 100644
index 0000000..c4d87f5
--- /dev/null
+++ b/.claude/skills/antithesis-debug/references/simplified-debugger.md
@@ -0,0 +1,419 @@
+# Simplified Debugger
+
+The simplified debugger provides a direct UI for browsing logs, running bash
+commands, and extracting files from containers — without the Monaco notebook
+editor.
+
+Prefer this mode for straightforward tasks: running shell commands, reading
+files, listing directories, and extracting artifacts. Switch to advanced mode
+only when you need the full notebook API (branching, event sets, programmatic
+inspection).
+
+**Note:** Different tenants may open the debugger in either simplified or
+advanced mode by default. After injecting the runtime, check with
+`window.__antithesisDebug.getMode()`. If it returns `"advanced"` and you want
+simplified mode, call `window.__antithesisDebug.switchMode("simplified")`.
+
+## Runtime injection
+
+The simplified debugger uses the same runtime as advanced mode. Inject it after
+the page loads:
+
+```bash
+cat assets/antithesis-debug.js \
+  | agent-browser --session "$SESSION" eval --stdin
+```
+
+Then check which mode is active and wait for readiness:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.getMode()"
+```
+
+If the result is `"advanced"`, switch to simplified:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.switchMode("simplified")'
+```
+
+Then wait for the simplified view to be ready:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.waitForReady()"
+```
+
+## Layout
+
+The page has three sections:
+
+1. **Log view** (top) — virtual-scrolling timeline of events from the test run.
+   Each row shows a vtime, source process, container name, and log output.
+2. **Command input** (bottom) — moment display, container selector, bash text
+   area, and Send button.
+3. **Output area** (middle, appears after sending) — results of commands and
+   file extractions stack up between the log view and the input area.
+
+## Selecting a moment
+
+Click any row in the log view to set the debugger's moment and container.
+
+The moment is a point in virtual time during the Antithesis test run. When you
+click a log line:
+
+- The vtime input updates to that row's virtual time.
+- The container selector updates to the container that emitted that log line.
+
+### Setting vtime programmatically
+
+To anchor the debugger at an arbitrary vtime (e.g. for a time-travel sweep
+between two moments), you have two options:
+
+1. **`clickLogRow(N)` on a row from the desired moment.** Simple when the
+   target moment has a visible log row — clicking it sets both the
+   moment and the container. The committed moment used by `runCommand`
+   updates immediately. (Note the displayed input value lags by one
+   click; trust `waitForNewOutput`'s `header` instead of `getMoment`.)
+2. **Real keyboard input on the vtime input field.** Required when the
+   target vtime has no visible log row. The runtime does NOT expose a
+   `setMoment(vt)` helper for this — the page's vtime input commits via
+   React's `focusout` handler, which only fires on *trusted* (real)
+   events. Synthetic blur from in-page JS doesn't trigger the commit, so
+   any in-page setter would update the display without changing the
+   committed moment. Drive real key events from agent-browser:
+
+   ```bash
+   # One-time: find the vtime input ref
+   agent-browser --session "$SESSION" snapshot -i -s "#ceres-time-input"
+   # => textbox [ref=e19]: ...
+
+   agent-browser --session "$SESSION" click @e19
+   agent-browser --session "$SESSION" fill @e19 "$VT"
+   agent-browser --session "$SESSION" keyboard type $'\t'   # real Tab fires the focusout that commits the value
+   sleep 2                                                  # let React state settle before runCommand
+   ```
+
+   **Pitfall: vtime input briefly `[disabled]` after page load.** Even after
+   `simplified.waitForReady()` returns `ready: true`, the input can render as
+   `textbox [disabled, ref=eNN]` for a second or two. `fill` against a
+   disabled input silently fails (`getMoment()` then returns `vtime: ""`).
+   Re-snapshot after a short sleep; once the snapshot shows the textbox
+   without the `[disabled]` marker, the `fill`+Tab sequence will commit.
+
+   In a sweep, also add a `sleep 2` *after* each `waitForNewOutput`.
+   Without this inter-iteration spacing, the loop alternates
+   success/failure on a 2-cycle (every other iteration times out with
+   `CDP command timed out: Runtime.evaluate` or returns a null `header`).
+
+Whichever path you use, the authoritative record of what vtime a command
+actually ran at is the `header` field of `waitForNewOutput`'s result —
+that text is generated by the page from the executed-at vtime, not from
+the displayed input.
+
+### Reading the current moment and container
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.getMoment()"
+```
+
+Returns `{ ok, vtime }`. **`getMoment` is unreliable** — it reads
+`input.value` (displayed value), and after a `clickLogRow(...)` it can lag
+the actually-committed moment by one cycle. Prefer reading the vtime out
+of `waitForNewOutput`'s `header` field once a command has run; that is
+authoritative.
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.getContainer()"
+```
+
+Returns `{ ok, container }`. On a fresh page load this can return
+`(host)` even when the anchored log row was emitted by a different
+container. Run a `clickLogRow(...)` on a row from your target container
+before issuing the first `runCommand` to set both moment and container
+deterministically.
+
+### Listing available containers
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.getContainers()"
+```
+
+Returns `{ ok, containers }` with deduplicated container names from the
+dropdown.
+
+### Browsing log rows
+
+Get visible log rows with their text and position:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.getVisibleLogRows()"
+```
+
+Returns `{ ok, rows, totalCount }`. Each row has `index`, `text`, and
+`isAnchor` (whether it's the currently selected moment).
+
+> **Important:** `index` is into the currently-rendered DOM items, NOT a
+> stable position in the full log. The virtual scroller mounts only the
+> rows in the visible window. `totalCount` is also the rendered count —
+> the actual total number of log events in the run can be much higher.
+> A row's index can shift when the scroller materializes new items or
+> when new output appends to the bottom (each `runCommand` adds rows).
+> Always pair `getVisibleLogRows` with `clickLogRow` in the same step;
+> don't cache an index for later use.
+
+Click a specific row to set the moment:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.clickLogRow(5)"
+```
+
+> **`clickLogRow` updates the moment AND the container** based on the
+> row's emitter. Clicking a `[host]`-emitted row (e.g. a `fault_injector`
+> event) pulls the container away from whatever you previously had. If a
+> sweep through rows hops emitters, your commands will execute in
+> different containers across iterations.
+
+The simplified runtime does NOT expose a `setContainer(name)` helper, but
+you have two ways to pin one without re-anchoring on a log row:
+
+1. **Click the container dropdown directly via `agent-browser`.** The
+   dropdown is a `[role="listbox"]` whose options become snapshotable refs
+   after the listbox is opened. This is reliable even when no visible log
+   row matches the desired container:
+
+   ```bash
+   # 1. Find the closed listbox (its current selection is clickable):
+   agent-browser --session "$SESSION" snapshot -i 2>&1 | grep -A1 '\[ref='
+   # => listbox [ref=e13]
+   #      generic "(host)" [ref=e20] clickable [cursor:pointer, tabindex]
+
+   # 2. Click the current-selection chip to open the list:
+   agent-browser --session "$SESSION" click @e20
+   sleep 1
+
+   # 3. Re-snapshot the listbox — options now have refs:
+   agent-browser --session "$SESSION" snapshot -i -s '[role="listbox"]'
+   # =>   option "(host)"        [ref=e27]
+   #      option "fdb-server-1"  [ref=e28]
+   #      option "workload-1"    [ref=e30]
+   #      ...
+
+   # 4. Click the option you want:
+   agent-browser --session "$SESSION" click @e28
+   agent-browser --session "$SESSION" eval \
+     "window.__antithesisDebug.simplified.getContainer()"
+   # => { container: "fdb-server-1", ok: true }
+   ```
+
+   The dropdown lists only containers running at the MVD recreate moment.
+   To inspect a container that had already crashed, you must use advanced
+   mode (see the main SKILL.md "when to escalate" list).
+
+2. **Click a log row from that container** with `clickLogRow(N)`. Simpler
+   when a visible row matches, but `clickLogRow` also resets the moment
+   to that row's vtime — so do the row click first, then set vtime via
+   `fill`+`Tab` (which doesn't touch the container).
+
+## Running a bash command
+
+1. Send a command:
+
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     'window.__antithesisDebug.simplified.runCommand("ls -la /opt/antithesis/")'
+   ```
+
+   Returns `{ ok, sent, script, outputCountBefore }`. Save `outputCountBefore`
+   for polling.
+
+2. Wait for the result (commands typically take 3–10 seconds):
+
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     "window.__antithesisDebug.simplified.waitForNewOutput(COUNT_BEFORE)"
+   ```
+
+   Replace `COUNT_BEFORE` with the value from step 1. Returns the output with
+   `{ ok, header, lines, lineCount, downloadLink, attempts, waitedMs }`.
+
+   Pass options for a different timeout:
+
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     "window.__antithesisDebug.simplified.waitForNewOutput(COUNT_BEFORE, { timeoutMs: 60000 })"
+   ```
+
+3. Or read the latest output directly (non-polling):
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     "window.__antithesisDebug.simplified.getLastOutput()"
+   ```
+
+### Command isolation
+
+Each `runCommand` runs in its **own observation branch** — an alternative
+fork of time, independent of every other `runCommand`. Two practical
+consequences:
+
+1. **No `/tmp` persistence across commands.** If command A writes
+   `/tmp/foo` and command B tries to read it, B sees nothing — they ran
+   on different branches. **Pack each probe into a single command**:
+   write the file, grep it, summarize it, all in one script. If the
+   script is large or has nasty quoting, base64-encode it locally and
+   send `echo $B64 | base64 -d | bash` (see
+   `references/common-inspections.md` → "Probe with a base64-packaged
+   script").
+2. **No side effects on the main timeline.** Mutations from one
+   `runCommand` are not visible to a later `runCommand` or to the
+   subsequent timeline you'd see by advancing the recreate moment.
+
+### Pitfall: passing the wrong `countBefore` to `waitForNewOutput`
+
+`runCommand` returns `outputCountBefore` — the count BEFORE the new
+command. Pass that exact integer to `waitForNewOutput(countBefore, ...)`.
+Passing a stale value (e.g. always `0`) makes `waitForNewOutput` return
+the FIRST output it finds past that index, which is almost certainly the
+previous probe's stale output — `attempts: 1, waitedMs: 0` is the
+fingerprint. Always thread the count through:
+
+```bash
+RESP=$(agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.runCommand($SCRIPT_JSON)")
+COUNT=$(printf '%s' "$RESP" | jq -r '.outputCountBefore')
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.waitForNewOutput($COUNT, { timeoutMs: 60000 })"
+```
+
+## Extracting and downloading a file
+
+1. Send an extraction request:
+
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     'window.__antithesisDebug.simplified.extractFile("/path/to/file")'
+   ```
+
+   Returns `{ ok, sent, path, outputCountBefore }`.
+
+2. Wait for the result:
+
+   ```bash
+   agent-browser --session "$SESSION" eval \
+     "window.__antithesisDebug.simplified.waitForNewOutput(COUNT_BEFORE)"
+   ```
+
+   The output for a file extraction includes a `downloadLink` field with
+   `{ text, href }` — the text shows the filename and size, the href is a
+   blob URL.
+
+3. Snapshot the output to get a clickable ref for the download link:
+
+   ```bash
+   agent-browser --session "$SESSION" snapshot -i -s ".ceres_output:last-child"
+   ```
+
+   This returns a ref like `@e13` for the download link.
+
+4. Download the file using the ref. The path argument to `agent-browser
+   download` is a **file path** (not a directory). Pass the full target
+   filename:
+
+   ```bash
+   mkdir -p /tmp/extracted
+   agent-browser --session "$SESSION" download @REF /tmp/extracted/workload-entrypoint.sh
+   ```
+
+   Passing a directory path with a trailing slash fails with
+   `Failed to rename downloaded file: Is a directory (os error 21)`.
+
+5. Read the downloaded file:
+   ```bash
+   cat /tmp/extracted/workload-entrypoint.sh
+   ```
+
+### Complete example
+
+```bash
+# Extract
+agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.simplified.extractFile("/opt/antithesis/workload-entrypoint.sh")'
+# => { ok: true, outputCountBefore: 0, ... }
+
+# Wait for output
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.waitForNewOutput(0)"
+# => { ok: true, downloadLink: { text: "Download workload-entrypoint.sh (1278 bytes)", ... }, ... }
+
+# Get the download link ref
+agent-browser --session "$SESSION" snapshot -i -s ".ceres_output:last-child"
+# => link "Download workload-entrypoint.sh (1278 bytes)" [ref=e5]
+
+# Download to a local file path (NOT a directory)
+mkdir -p /tmp/extracted
+agent-browser --session "$SESSION" download @e5 /tmp/extracted/workload-entrypoint.sh
+
+# Read the file
+cat /tmp/extracted/workload-entrypoint.sh
+```
+
+## Reading previous outputs
+
+All command and extraction results stack in the output area.
+
+```bash
+# Count of output sections
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.getOutputCount()"
+
+# All output headers
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.getOutputHeaders()"
+
+# Read a specific output by index
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.getOutput(0)"
+```
+
+## Filtering and searching logs
+
+```bash
+# Filter logs to show only matching rows
+agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.simplified.filterLogs("error")'
+
+# Clear the filter
+agent-browser --session "$SESSION" eval \
+  "window.__antithesisDebug.simplified.clearFilter()"
+```
+
+> Observed: `filterLogs(...)` does NOT always narrow `getVisibleLogRows()`'s
+> output. The implementation writes via `setNativeValue(...)` only and
+> doesn't dispatch the `input` event React listens for, so the filter
+> sometimes doesn't apply. Treat this method as best-effort; if filtering
+> matters for what you're trying to find, switch to advanced mode and use
+> `environment.events.contains({...}).up_to(branch)` instead.
+
+## Switching to advanced mode
+
+```bash
+agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.switchMode("advanced")'
+```
+
+After switching, follow the `references/notebook.md` and
+`references/actions.md` guides. The notebook API is available at
+`window.__antithesisDebug.notebook` and `window.__antithesisDebug.actions`.
+
+To return to simplified mode:
+
+```bash
+agent-browser --session "$SESSION" eval \
+  'window.__antithesisDebug.switchMode("simplified")'
+```
diff --git a/.claude/skills/antithesis-documentation/SKILL.md b/.claude/skills/antithesis-documentation/SKILL.md
new file mode 100644
index 0000000..f323680
--- /dev/null
+++ b/.claude/skills/antithesis-documentation/SKILL.md
@@ -0,0 +1,91 @@
+---
+name: antithesis-documentation
+description: Use Antithesis documentation efficiently for product, workflow, and integration questions. Prefer the snouty docs CLI when available, and otherwise request markdown versions of documentation pages directly.
+compatibility: Requires snouty (https://github.com/antithesishq/snouty).
+metadata:
+  version: "2026-07-14 1f59c97"
+---
+
+# Antithesis Documentation
+
+**Skill version:** `2026-07-14 1f59c97`
+
+## Antithesis Overview
+
+Antithesis is a testing platform that works like a specialized staging environment. You ensure your software is reliable by deploying it to Antithesis and running it there before you deploy it to production. It supplements your existing testing tools and lives alongside your normal CI/CD workflow.
+
+When you deploy to Antithesis, your software runs in a simulation environment that is much more hostile than production. This quickly exposes bugs, including complicated, unlikely, and severe failures.
+
+Because Antithesis's environment is perfectly deterministic, problems are reproducible with minimal effort. Unlike typical shared staging, you do not need to compete for deployment locks or worry about environmental drift since every deployment is completely isolated from one another.
+
+## Accessing Documentation
+
+The best way to access Antithesis documentation on the command line is via the Antithesis CLI which is called snouty.
+
+Run `snouty docs --help` to get started.
+
+## If `snouty` is missing
+
+1. Tell the user `snouty` is the Antithesis CLI.
+2. Point them to the install source: `https://github.com/antithesishq/snouty`
+3. Ask whether they want you to install it. If yes, follow instructions in `https://raw.githubusercontent.com/antithesishq/snouty/refs/heads/main/README.md`.
+4. After installation, re-run `snouty --help`.
+
+## Using `snouty docs`
+
+Use `snouty docs` to discover authoritative Antithesis documentation before giving detailed guidance. Inspect `snouty docs --help` to discover subcommands and usage examples.
+
+Recommended workflow:
+
+1. Start with `snouty docs tree --depth 2` to get a quick overview of the docs.
+2. Use `snouty docs tree ` to explore a section when you know the area but not the exact page name.
+3. Use `snouty docs search ` to find likely pages for a specific topic.
+4. Use `snouty docs search -l ` when you want just the page paths.
+5. Use `snouty docs show ` to read the full markdown page once you know the path.
+6. Cite the relevant documentation pages in your answer.
+
+Useful details:
+
+- `snouty docs show` accepts page paths like `using_antithesis/sdk/go`.
+- `snouty docs show` also accepts `/docs/.../` style paths and tries to normalize them for you.
+- A warning about failing to update docs and falling back to cached docs is usually fine, especially in sandboxes without network access. Treat it as non-fatal if the requested docs content is still returned.
+- `snouty docs sqlite` prints the path to a local SQLite database containing all of the Antithesis documentation. Use this if you want to directly query the docs.
+
+## Direct Markdown Fallback
+
+If `snouty` is unavailable, you may fetch markdown pages directly from `https://antithesis.com/docs/`.
+
+A plain text index of all markdown pages is available at `https://antithesis.com/docs/llms.txt`. Load this first.
+
+Always add the `.md` extension before requesting files from `https://antithesis.com/docs/`.
+
+Examples:
+
+- `https://antithesis.com/docs/reference/sdk/go/` becomes `https://antithesis.com/docs/reference/sdk/go.md`
+- `/reference/sdk/go/` becomes `https://antithesis.com/docs/reference/sdk/go.md`
+
+Exceptions:
+
+- URLs with explicit file extensions such as `.txt`, `.js`, or `.so`
+- `docs/generated/...` paths should be requested as-is
+
+When presenting links to the user, prefer the normal HTML page URL instead of the `.md` URL.
+
+If you want to link a user directly to a section, use a fragment with the slugified header when practical. If the slug is uncertain, link the page and name the section explicitly.
+
+## Output
+
+- Clear, grounded answers about Antithesis behavior, SDKs, setup, and best practices.
+- Relevant links to the documentation pages you used.
+- If the `snouty` command is missing ask the user if they want to install it, telling them that it is a CLI for working with the Antithesis API and docs.
+
+## Self-Review
+
+Before declaring this skill complete, review your work against the criteria below. This skill's output is conversational (answers grounded in documentation), so the review should happen in your current context. Re-read the guidance in this file, then systematically check each item below against the answers you produced.
+
+Review criteria:
+
+- Every factual claim in your answer is grounded in a specific documentation page you retrieved via `snouty docs` or direct markdown fetch
+- Documentation page links are included so the user can verify your sources
+- You have not mixed up concepts from different pages or added details not present in the source material
+- If the docs were ambiguous or silent on a point, you said so rather than filling the gap with assumptions
diff --git a/.claude/skills/antithesis-launch/SKILL.md b/.claude/skills/antithesis-launch/SKILL.md
new file mode 100644
index 0000000..3621b04
--- /dev/null
+++ b/.claude/skills/antithesis-launch/SKILL.md
@@ -0,0 +1,86 @@
+---
+name: antithesis-launch
+description: >
+  Launch an Antithesis run with snouty by discovering the harness layout,
+  building the right Docker Compose config, running `snouty validate`,
+  bailing on validation failure, and then submitting `snouty launch` with sane
+  metadata. Use when the user wants to send, submit, or launch an Antithesis
+  run. This skill takes duration in minutes as input.
+compatibility: Requires docker (or podman) with compose and snouty (https://github.com/antithesishq/snouty).
+metadata:
+  version: "2026-07-14 1f59c97"
+---
+
+# Antithesis Launch
+
+**Skill version:** `2026-07-14 1f59c97`
+
+## Prerequisites
+
+- DO NOT PROCEED if `snouty` is not installed. See `https://raw.githubusercontent.com/antithesishq/snouty/refs/heads/main/README.md` for installation options.
+
+## Goal
+
+Launch an Antithesis run in this order only:
+
+1. `docker compose build`
+2. `snouty validate`
+3. if validation fails, stop and report the error
+4. `snouty launch`
+
+## Required Input
+
+- `duration` in minutes is required. If the user did not provide it, ask before submitting the run.
+
+## Discovery
+
+- Start from any user-provided path, command, or Antithesis directory name.
+- Otherwise, inspect the repo to understand how the harness is wired. Check nearby `AGENTS.md`, `README*`, `Makefile*`, and Antithesis-specific scripts before choosing commands.
+- Find the config directory by locating the `docker-compose.yaml` intended for Antithesis. Prefer directories like `antithesis/config`, but support non-standard layouts.
+- Treat these as strong Antithesis signals: nearby `scratchbook/` or `test/` directories, compose content mentioning `/opt/antithesis`, `ANTITHESIS_` env vars, `setup_complete`, or existing `snouty` examples.
+- If multiple compose files look plausible, prefer the one referenced by repo docs or existing `snouty launch` examples. If the choice is still ambiguous, ask the user instead of guessing.
+- Use the directory containing `docker-compose.yaml` as the `snouty validate ` and `snouty launch --config ` argument.
+- Build against that exact file with `docker compose -f /docker-compose.yaml build`. If `docker compose` is unavailable, fall back to `docker-compose -f ... build`.
+
+## Run Arguments
+
+- Determine the webhook in this order: explicit user input, existing repo docs/scripts/examples, otherwise default to `basic_test` when using a docker-compose.yaml file and to `basic_k8s_test` when using a kubernetes setup.
+
+- `snouty launch --config` requires `ANTITHESIS_REPOSITORY`. Reuse the current environment if it is already set. If not, stop and ask the user for it.
+- Always set all of these explicitly:
+  - `--duration`: the user-provided duration
+  - `--source`: repo name
+  - `--test-name`: repo name plus branch or config name
+  - `--description`: short, readable description of the run, including details such as the branch name, currently goal, or what you changed since the last run.
+
+## Execution
+
+- These commands can take a long time. Prefer background execution or generous timeouts instead of assuming quick completion.
+- Do not run `snouty launch` unless the build succeeded and `snouty validate` exited successfully.
+
+```sh
+docker compose -f "$CONFIG_DIR/docker-compose.yaml" build
+snouty validate "$CONFIG_DIR"
+snouty launch \
+  --json \
+  --webhook "$WEBHOOK" \
+  --config "$CONFIG_DIR" \
+  --duration "$DURATION" \
+  --source "$SOURCE" \
+  --test-name "$TEST_NAME" \
+  --description "$DESCRIPTION"
+```
+
+## Output
+
+- Report the config directory, compose build command, validate command, and final `snouty launch` command shape before submission.
+- If validation fails, stop immediately and show the failing command plus the key error.
+- The `--json` flag makes `snouty launch` emit machine-readable output containing a `run_id`. Parse and report the run_id — it's needed to triage the run when it is done.
+
+## Self-Review
+
+- The chosen config directory is the one that actually contains the Antithesis `docker-compose.yaml`.
+- The build, validate, and run steps all point at the same config.
+- `snouty validate` succeeded before `snouty launch` was invoked.
+- The run set `source`, `test-name`, `description`, and `duration` explicitly.
+- Missing blockers such as `duration`, `ANTITHESIS_REPOSITORY`, or an ambiguous config location caused a stop instead of a bad submission.
diff --git a/.claude/skills/antithesis-query-logs/SKILL.md b/.claude/skills/antithesis-query-logs/SKILL.md
new file mode 100644
index 0000000..b91e778
--- /dev/null
+++ b/.claude/skills/antithesis-query-logs/SKILL.md
@@ -0,0 +1,228 @@
+---
+name: antithesis-query-logs
+description: >
+  Search across all timelines in an Antithesis test run to find events,
+  correlate property failures, and answer temporal questions about ordering
+  and causation (e.g., did event A always precede failure B? do failures
+  occur even without a preceding fault?).
+compatibility: Requires snouty (https://github.com/antithesishq/snouty) and agent-browser (https://github.com/vercel-labs/agent-browser).
+metadata:
+  version: "2026-07-14 1f59c97"
+---
+
+# Antithesis Logs Explorer
+
+**Skill version:** `2026-07-14 1f59c97`
+
+## Purpose and Goal
+
+Search across all timelines in an Antithesis test run to find events,
+correlate property failures, and answer temporal questions about ordering
+and causation — cascade elimination, fault correlation, root cause hypothesis
+testing, and any "if X happened before Y" query.
+
+The Logs Explorer is distinct from the per-example log viewer used in triage.
+The triage log viewer shows one timeline centered on one event. The Logs
+Explorer searches across ALL timelines in a run, supports temporal queries
+(preceded by, not preceded by, followed by, not followed by), and visualizes
+results on a multiverse map.
+
+## When to Use
+
+- Investigating whether a property failure is independent or a cascade from
+  an earlier failure
+- Counting how many independent occurrences of a failure exist across timelines
+- Correlating failures with fault injection events
+- Comparing failure patterns between backend configurations
+- Identifying timeline clusters where failures co-occur
+
+## Prerequisites
+
+
+- DO NOT PROCEED if `snouty` is not installed. See `https://raw.githubusercontent.com/antithesishq/snouty/refs/heads/main/README.md` for installation options.
+- DO NOT PROCEED if `agent-browser` is not installed. See `https://raw.githubusercontent.com/vercel-labs/agent-browser/refs/heads/main/README.md` for installation options.
+- DO NOT PROCEED if `agent-browser` is older than version `v0.23.4`. You can upgrade with `agent-browser upgrade`.
+- DO NOT PROCEED if `jq` is not installed. See `https://jqlang.org/download/` for installation options.
+- `python3` (>= 3.10, stdlib only) for `assets/build-url.py`.
+- A completed Antithesis run. If you don't have a specific run URL, use the
+  `antithesis-triage` skill's run discovery workflow to find one first.
+- `agent-browser` installed and authenticated to the Antithesis tenant
+
+## Key Concepts
+
+- **Temporal query**: Search for event X that is (or is not) preceded by or
+  followed by event Y. Used to filter out cascade failures.
+- **Multiverse map**: Visualization of the timeline tree. Failures appear as
+  dots. Clusters of dots on related timelines suggest a shared root cause.
+- **Independent cluster count**: The number of distinct timeline branches
+  where a failure appears. "8 failures in 2 clusters" is much more informative
+  than just "8 failures" — it tells you how many independent reproductions exist.
+- **Session ID**: Each run has a unique session ID that scopes all queries.
+  Queries against the wrong session return no results. The session ID is
+  embedded in the encoded search URL parameter.
+
+## Important: Field Names
+
+The Logs Explorer uses **singular** field names, not plural:
+
+- `assertion.message` — the property/assertion name (NOT `assertions.message`)
+- `assertion.status` — `passing` or `failing` (NOT `assertions.status`)
+- `general.output_text` — log output text
+
+The `assertion.status` field requires the **`matches`** operator, not
+`contains`. Other text fields use `contains`.
+
+## Session Setup
+
+Use `agent-browser` with the shared Antithesis authentication:
+
+```
+SESSION="logs-explorer-$(date +%s)-$$"
+agent-browser --session "$SESSION" --session-name antithesis --profile antithesis --args "--no-sandbox"
+```
+
+## Launching the Logs Explorer
+
+There are three ways to reach the Logs Explorer with the correct run selected:
+
+### Method 1: From a triage report (preferred)
+
+Navigate to the triage report first, then extract the "Explore logs" link.
+This is the most reliable method because the link contains the correct session
+ID already encoded in the URL.
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "document.querySelector('a.an-button[href*=\"/search\"]').href"
+```
+
+Navigate to the returned URL. This automatically sets the correct session/run.
+
+The URL will have the form:
+
+```
+https://{tenant}.antithesis.com/search?search=v5v{base64_encoded_query}
+```
+
+The `v5v` prefix is a version marker. The base64 payload contains the query
+JSON including the session ID (`s` field). See `references/query-builder.md`
+for the full query JSON format.
+
+### Method 2: From the Logs Explorer directly
+
+The Logs Explorer page has a "Show me logs from" dropdown at the top
+(`div.select_container.event_search_run_selector`). Click it to see a list
+of recent runs with their name, status (In progress / Completed / Incomplete),
+and timestamp. Select the run you want.
+
+If a run you expect to see is missing, refresh the page.
+
+### Method 3: From the sidebar
+
+Click "Logs explorer" in the left sidebar navigation. This goes directly to
+`https://{tenant}.antithesis.com/search` with no run pre-selected. Then use
+the "Show me logs from" dropdown to choose a run.
+
+```bash
+agent-browser --session "$SESSION" open "https://{tenant}.antithesis.com/search"
+```
+
+**Important**: Always ensure the correct run is selected before searching.
+Each run has its own session ID — queries against the wrong session return
+no results. The selected run is shown in the dropdown at the top of the page.
+
+## Reference Files
+
+| Reference                        | When to read                                     |
+| -------------------------------- | ------------------------------------------------ |
+| `references/query-builder.md`    | Building and executing search queries            |
+| `references/temporal-queries.md` | Using preceded-by / not-preceded-by filters      |
+| `references/results.md`          | Reading search results and clicking into details |
+| `references/map.md`              | Using the multiverse map for cluster analysis    |
+
+## URL Construction (preferred) and Runtime Injection
+
+This skill ships two assets. Pick the one that fits the step you're on:
+
+- **`assets/build-url.py`** — standalone Python CLI. Builds and decodes
+  Logs Explorer URLs without any browser. Use this any time you just need
+  a URL (e.g. to hand to `agent-browser open` or to paste into a browser).
+  No injection, no DOM, no session needed.
+
+  ```bash
+  python3 assets/build-url.py failure \
+    --session-id "$SESSION_ID" \
+    --property "my-failing-property" \
+    --tenant "{tenant}.antithesis.com"
+  ```
+
+  See `references/query-builder.md` for all subcommands (`failure`,
+  `not-preceded-by`, `not-followed-by`, `custom`, `decode`).
+
+- **`assets/antithesis-query-logs.js`** — in-page runtime. Inject this on
+  the Logs Explorer page only when you need to *interact with* the page:
+  click Search, wait for results, read counts/result rows, switch to the
+  Map view, etc. Pure URL construction does not need the runtime.
+
+  ```bash
+  cat assets/antithesis-query-logs.js \
+    | agent-browser --session "$SESSION" eval --stdin
+  ```
+
+  The runtime registers `window.__antithesisQueryLogs`. If you get
+  `TypeError: Cannot read properties of undefined` from a call against it,
+  the runtime has not been injected on the current page — inject first.
+
+Prefer `build-url.py` whenever you just need a URL. Drop to the in-page
+runtime only for steps that genuinely have to touch the DOM.
+
+## Recommended Workflows
+
+### Cascade elimination
+
+1. Get the session ID — either by extracting it from the triage report's
+   "Explore logs" link (`python3 assets/build-url.py decode --url "$EXPLORE_LOGS_URL" | jq -r .s`)
+   or from any existing Logs Explorer URL.
+2. Build a simple failure URL with the Python CLI:
+   ```bash
+   python3 assets/build-url.py failure \
+     --session-id "$SESSION_ID" --property "my-failing-property" \
+     --tenant "{tenant}.antithesis.com"
+   ```
+3. `agent-browser open` the URL, inject the runtime, run search, read the
+   count via `window.__antithesisQueryLogs.search()`.
+4. Build a temporal URL:
+   ```bash
+   python3 assets/build-url.py not-preceded-by \
+     --session-id "$SESSION_ID" --property "my-failing-property" \
+     --pre-field assertion.message --pre-value "suspected-upstream-failure" \
+     --tenant "{tenant}.antithesis.com"
+   ```
+5. Navigate to it, run search, note the new count.
+6. If the count drops, the difference is cascade failures. If it stays the
+   same, the failures are independent.
+
+### Failure correlation with event details
+
+1. Search for a property failure
+2. Expand the `{}` details on several results
+3. Identify detail fields that vary across results (e.g., configuration
+   modes, feature flags, container names, protocol versions)
+4. Note whether failures cluster on a specific detail value — if all
+   failures share a common detail that other passing events do not, that
+   detail is likely part of the root cause
+
+### Independent cluster analysis
+
+1. Search for a property failure
+2. Switch to the Map tab
+3. Count the number of distinct timeline branches with failure dots
+4. Failures on the same branch are likely the same root cause
+5. Pick one representative from each cluster for investigation
+
+## Output
+
+- Event counts (total matches, matches after temporal filtering)
+- Event details (timestamp, timeline, assertion details and custom key-value pairs)
+- Cluster count from the multiverse map
+- Log context around each failure (fault events, preceding operations)
diff --git a/.claude/skills/antithesis-query-logs/assets/antithesis-query-logs.js b/.claude/skills/antithesis-query-logs/assets/antithesis-query-logs.js
new file mode 100644
index 0000000..6eede81
--- /dev/null
+++ b/.claude/skills/antithesis-query-logs/assets/antithesis-query-logs.js
@@ -0,0 +1,731 @@
+(function () {
+  var VERSION = "0.2.0";
+
+  function clean(text) {
+    return (text || "").replace(/\s+/g, " ").trim();
+  }
+
+  function wait(ms) {
+    return new Promise(function (resolve) {
+      setTimeout(resolve, ms);
+    });
+  }
+
+  function click(el) {
+    if (!el) return false;
+    ["pointerdown", "mousedown", "mouseup", "click"].forEach(function (type) {
+      el.dispatchEvent(
+        new MouseEvent(type, {
+          bubbles: true,
+          cancelable: true,
+          composed: true,
+          view: window,
+        }),
+      );
+    });
+    return true;
+  }
+
+  function setTextareaValue(textarea, value) {
+    if (!textarea) return false;
+    textarea.value = value;
+    textarea.dispatchEvent(new Event("input", { bubbles: true }));
+    textarea.dispatchEvent(new Event("change", { bubbles: true }));
+    return true;
+  }
+
+  function findLabelByText(text) {
+    return Array.from(document.querySelectorAll("label")).find(function (l) {
+      return clean(l.textContent) === text;
+    });
+  }
+
+  function findClickableByText(text) {
+    var candidates = Array.from(
+      document.querySelectorAll("label, button, a, span, div"),
+    );
+    return candidates.find(function (el) {
+      var ownText = "";
+      for (var child of el.childNodes) {
+        if (child.nodeType === 3) ownText += child.textContent;
+      }
+      return clean(ownText) === text;
+    });
+  }
+
+  // -------------------------------------------------------------------------
+  // Page detection
+  // -------------------------------------------------------------------------
+
+  function isLogsExplorerPage() {
+    return (
+      window.location.pathname === "/search" &&
+      !/[?&]get_logs=true\b/.test(window.location.search)
+    );
+  }
+
+  function requireLogsExplorer() {
+    if (!isLogsExplorerPage()) {
+      return {
+        error: "expected Logs Explorer page (not per-example log viewer)",
+        url: window.location.href,
+      };
+    }
+    return null;
+  }
+
+  // -------------------------------------------------------------------------
+  // URL construction helpers (preferred approach)
+  // -------------------------------------------------------------------------
+
+  /**
+   * Build a query condition object.
+   * @param {string} field - e.g. "assertion.message", "assertion.status"
+   * @param {string} operator - "contains", "matches", "excludes", "regex"
+   * @param {string} value - the search value
+   * @param {boolean} [caseSensitive=false]
+   */
+  function cond(field, operator, value, caseSensitive) {
+    return {
+      c: caseSensitive === true,
+      f: field,
+      o: operator,
+      v: value,
+    };
+  }
+
+  /**
+   * Build a condition group (wraps conditions in the h/o structure).
+   * @param {Array} conditions - array of condition objects from cond()
+   * @param {string} [joinOp="or"] - how conditions within this group join
+   */
+  function condGroup(conditions, joinOp) {
+    return {
+      h: conditions,
+      o: joinOp || "or",
+    };
+  }
+
+  /**
+   * Build a row group (wraps condition groups in the r/h/o structure).
+   * @param {Array} groups - array of condition group objects
+   * @param {string} [joinOp="and"] - how groups are joined ("and" or "or")
+   */
+  function rowGroup(groups, joinOp) {
+    return {
+      r: {
+        h: groups,
+        o: joinOp || "and",
+      },
+    };
+  }
+
+  /**
+   * Build a complete query JSON object.
+   * @param {Object} options
+   * @param {string} options.sessionId - the run session ID
+   * @param {Array} options.conditions - array of {field, op, value} objects
+   *   for the main WHERE block
+   * @param {string} [options.temporalType="none"] - "none",
+   *   "preceded_by", "not_preceded_by", "followed_by", "not_followed_by"
+   * @param {Array} [options.temporalConditions] - array of {field, op, value}
+   *   objects for the temporal block (required if temporalType != "none")
+   */
+  /**
+   * Map the caller-facing temporal type to the platform's p-block encoding.
+   *
+   * Platform format (discovered 2026-04-03 against platform 50-6):
+   *   q.n.y is always "none" — even for temporal queries.
+   *   q.p.y is "preceding" or "following".
+   *   q.p.t.g encodes the negation: true = NOT, false = positive.
+   */
+  var TEMPORAL_MAP = {
+    preceded_by:       { y: "preceding",  negate: false },
+    not_preceded_by:   { y: "preceding",  negate: true },
+    followed_by:       { y: "following",  negate: false },
+    not_followed_by:   { y: "following",  negate: true },
+  };
+
+  function buildQuery(options) {
+    if (!options || typeof options !== "object") {
+      throw new Error("buildQuery: options object is required");
+    }
+    if (!options.sessionId) {
+      throw new Error("buildQuery: options.sessionId is required");
+    }
+    if (!Array.isArray(options.conditions) || options.conditions.length === 0) {
+      throw new Error(
+        "buildQuery: options.conditions must be a non-empty array of " +
+        "{field, op, value} objects (did you pass 'rows' instead?)"
+      );
+    }
+    var mainGroups = options.conditions.map(function (c) {
+      if (!c || typeof c !== "object" || !("field" in c) || !("op" in c) || !("value" in c)) {
+        throw new Error(
+          "buildQuery: each condition requires field, op, and value keys; got " +
+          JSON.stringify(c)
+        );
+      }
+      return condGroup([cond(c.field, c.op, c.value)]);
+    });
+
+    var query = {
+      q: {
+        n: Object.assign(rowGroup(mainGroups), {
+          t: { g: false, m: "" },
+          y: "none",
+        }),
+      },
+      s: options.sessionId,
+    };
+
+    if (options.temporalType && options.temporalType !== "none") {
+      if (
+        !Array.isArray(options.temporalConditions) ||
+        options.temporalConditions.length === 0
+      ) {
+        throw new Error(
+          "buildQuery: temporalType=" + JSON.stringify(options.temporalType) +
+          " requires a non-empty temporalConditions array"
+        );
+      }
+      var mapping = TEMPORAL_MAP[options.temporalType];
+      if (!mapping) {
+        throw new Error(
+          "buildQuery: unknown temporalType " + JSON.stringify(options.temporalType) +
+          "; expected one of " + Object.keys(TEMPORAL_MAP).join(", ")
+        );
+      }
+
+      var temporalGroups = options.temporalConditions.map(function (c) {
+        return condGroup([cond(c.field, c.op, c.value)]);
+      });
+
+      query.q.p = Object.assign(rowGroup(temporalGroups), {
+        t: { g: mapping.negate, m: "" },
+        y: mapping.y,
+      });
+    }
+
+    return query;
+  }
+
+  /**
+   * Encode a query object into the URL search parameter value.
+   * @param {Object} query - the query JSON from buildQuery()
+   * @returns {string} - the encoded string (v5v + base64)
+   */
+  function encodeQuery(query) {
+    var json = JSON.stringify(query);
+    var b64 = btoa(json).replace(/=+$/, "");
+    return "v5v" + b64;
+  }
+
+  /**
+   * Build a full search URL.
+   * @param {Object} query - the query JSON from buildQuery()
+   * @param {string} [tenant] - tenant hostname; defaults to current page hostname
+   * @returns {string} - the full URL
+   */
+  function buildSearchUrl(query, tenant) {
+    var host = tenant || window.location.hostname;
+    return (
+      "https://" + host + "/search?search=" + encodeQuery(query)
+    );
+  }
+
+  /**
+   * Decode the search parameter from the current page URL.
+   * @returns {Object|null} - the decoded query JSON, or null
+   */
+  function decodeCurrentQuery() {
+    var param = new URLSearchParams(window.location.search).get("search");
+    if (!param) return null;
+    try {
+      var b64 = param.slice(3); // strip "v5v"
+      while (b64.length % 4) b64 += "=";
+      return JSON.parse(atob(b64));
+    } catch (e) {
+      return null;
+    }
+  }
+
+  /**
+   * Extract the session ID from the current page URL.
+   * @returns {string|null}
+   */
+  function extractSessionId() {
+    var decoded = decodeCurrentQuery();
+    return decoded ? decoded.s : null;
+  }
+
+  // -------------------------------------------------------------------------
+  // Standalone URL builder — available without full runtime or page context
+  // -------------------------------------------------------------------------
+
+  window.__antithesisQueryBuilder = {
+    buildQuery: function (options) {
+      return buildQuery(options);
+    },
+    encodeQuery: function (query) {
+      return encodeQuery(query);
+    },
+    buildSearchUrl: function (queryOrOptions, tenant) {
+      if (!queryOrOptions || typeof queryOrOptions !== "object") {
+        throw new Error("buildSearchUrl: query or options object is required");
+      }
+      var looksLikeOptions =
+        "sessionId" in queryOrOptions ||
+        "conditions" in queryOrOptions ||
+        "temporalType" in queryOrOptions;
+      var query = looksLikeOptions ? buildQuery(queryOrOptions) : queryOrOptions;
+      return buildSearchUrl(query, tenant);
+    },
+    buildFailureQueryUrl: function (sessionId, assertionMessage, tenant) {
+      var query = buildQuery({
+        sessionId: sessionId,
+        conditions: [
+          { field: "assertion.message", op: "contains", value: assertionMessage },
+          { field: "assertion.status", op: "matches", value: "failing" },
+        ],
+      });
+      return buildSearchUrl(query, tenant);
+    },
+    buildNotPrecededByUrl: function (sessionId, assertionMessage, precededByField, precededByValue, tenant) {
+      var query = buildQuery({
+        sessionId: sessionId,
+        conditions: [
+          { field: "assertion.message", op: "contains", value: assertionMessage },
+          { field: "assertion.status", op: "matches", value: "failing" },
+        ],
+        temporalType: "not_preceded_by",
+        temporalConditions: [
+          { field: precededByField, op: "contains", value: precededByValue },
+        ],
+      });
+      return buildSearchUrl(query, tenant);
+    },
+    buildNotFollowedByUrl: function (sessionId, assertionMessage, followedByField, followedByValue, tenant) {
+      var query = buildQuery({
+        sessionId: sessionId,
+        conditions: [
+          { field: "assertion.message", op: "contains", value: assertionMessage },
+          { field: "assertion.status", op: "matches", value: "failing" },
+        ],
+        temporalType: "not_followed_by",
+        temporalConditions: [
+          { field: followedByField, op: "contains", value: followedByValue },
+        ],
+      });
+      return buildSearchUrl(query, tenant);
+    },
+  };
+
+  // -------------------------------------------------------------------------
+  // Query builder interactions (fallback UI approach)
+  // -------------------------------------------------------------------------
+
+  function getQueryTextareas() {
+    return Array.from(document.querySelectorAll("textarea.textarea_component"));
+  }
+
+  function getFieldSelectors() {
+    return Array.from(
+      document.querySelectorAll(".select_container.query_select"),
+    );
+  }
+
+  async function selectDropdownOption(selectContainer, optionText) {
+    // Click to open the dropdown
+    click(selectContainer);
+    await wait(500);
+
+    // Find and click the option
+    var options = Array.from(
+      document.querySelectorAll(
+        ".select_option, [class*=select_option], [role=option]",
+      ),
+    );
+    var target = options.find(function (el) {
+      return clean(el.textContent).includes(optionText);
+    });
+    if (target) {
+      click(target);
+      await wait(300);
+      return true;
+    }
+    return false;
+  }
+
+  // -------------------------------------------------------------------------
+  // Search execution
+  // -------------------------------------------------------------------------
+
+  function clickSearch() {
+    var searchLabel = findLabelByText("Search");
+    if (searchLabel) {
+      click(searchLabel);
+      return true;
+    }
+    return false;
+  }
+
+  async function waitForResults(timeoutMs) {
+    var deadline = Date.now() + (timeoutMs || 60000);
+    while (Date.now() < deadline) {
+      var count = getResultCount();
+      if (count !== null) {
+        return { ok: true, count: count, noResults: count === 0 };
+      }
+      await wait(1000);
+    }
+    return { ok: false, error: "timeout waiting for results" };
+  }
+
+  // -------------------------------------------------------------------------
+  // Result reading
+  // -------------------------------------------------------------------------
+
+  function readResults(limit) {
+    var maxItems = typeof limit === "number" && limit > 0 ? limit : 20;
+    var resultsArea = document.querySelector(".event_search_results");
+    if (!resultsArea) return [];
+
+    // Results are rows with timestamp, source, container, and event text
+    var rows = resultsArea.querySelectorAll("[class*=result], .event");
+    var results = [];
+
+    // Fallback: parse the text content directly
+    var text = resultsArea.innerText;
+    var lines = text.split("\n").filter(function (l) { return l.trim(); });
+
+    // Parse results positionally: each result starts with a vtime line,
+    // followed by source, container, and event text lines in order.
+    var currentResult = null;
+    var fieldIndex = 0;
+    for (var i = 0; i < lines.length && results.length < maxItems; i++) {
+      var line = lines[i].trim();
+      // vtime pattern: digits with decimal
+      if (/^\d+\.\d+$/.test(line)) {
+        if (currentResult) results.push(currentResult);
+        currentResult = { vtime: line, source: "", container: "", text: "" };
+        fieldIndex = 0;
+      } else if (currentResult) {
+        if (line.startsWith("{")) {
+          currentResult.text = line.substring(0, 500);
+        } else if (fieldIndex === 0) {
+          currentResult.source = line;
+          fieldIndex = 1;
+        } else if (fieldIndex === 1) {
+          currentResult.container = line;
+          fieldIndex = 2;
+        }
+      }
+    }
+    if (currentResult) results.push(currentResult);
+
+    return results;
+  }
+
+  function getResultCount() {
+    // The positive count lives in a sibling ``
+    // (e.g. "54,924 matching events"). The .event_search_results container
+    // shows the actual result rows or "No matching events" when empty.
+    var countEl = document.querySelector(".event_heading_count");
+    if (countEl) {
+      var countText = clean(countEl.textContent);
+      var match = countText.match(/(\d[\d,]*)\s*matching\s*events/);
+      if (match) return parseInt(match[1].replace(/,/g, ""), 10);
+      if (countText.includes("No matching events")) return 0;
+    }
+    var resultsArea = document.querySelector(".event_search_results");
+    if (resultsArea && clean(resultsArea.textContent).includes("No matching events")) {
+      return 0;
+    }
+    return null;
+  }
+
+  // -------------------------------------------------------------------------
+  // Temporal query controls (UI fallback)
+  // -------------------------------------------------------------------------
+
+  function clickPrecededBy() {
+    var el = findClickableByText("Preceded by");
+    if (el) {
+      click(el);
+      return true;
+    }
+    return false;
+  }
+
+  function clickFollowedBy() {
+    var el = findClickableByText("Followed by");
+    if (el) {
+      click(el);
+      return true;
+    }
+    return false;
+  }
+
+  async function switchToNotPrecededBy() {
+    // After clicking Preceded by, a dropdown appears.
+    // Find the dropdown that has "Preceded by" / "Not preceded by" options.
+    var dropdowns = Array.from(
+      document.querySelectorAll(".select_container"),
+    );
+    for (var d of dropdowns) {
+      var text = clean(d.textContent);
+      if (text.includes("Preceded by") && !text.includes("event_search_run")) {
+        click(d);
+        await wait(500);
+        var options = Array.from(
+          document.querySelectorAll(
+            ".select_option, [class*=select_option]",
+          ),
+        );
+        var notPreceded = options.find(function (el) {
+          return clean(el.textContent) === "Not preceded by";
+        });
+        if (notPreceded) {
+          click(notPreceded);
+          await wait(300);
+          return true;
+        }
+        break;
+      }
+    }
+    return false;
+  }
+
+  async function switchToNotFollowedBy() {
+    var dropdowns = Array.from(
+      document.querySelectorAll(".select_container"),
+    );
+    for (var d of dropdowns) {
+      var text = clean(d.textContent);
+      if (text.includes("Followed by") && !text.includes("event_search_run")) {
+        click(d);
+        await wait(500);
+        var options = Array.from(
+          document.querySelectorAll(
+            ".select_option, [class*=select_option]",
+          ),
+        );
+        var notFollowed = options.find(function (el) {
+          return clean(el.textContent) === "Not followed by";
+        });
+        if (notFollowed) {
+          click(notFollowed);
+          await wait(300);
+          return true;
+        }
+        break;
+      }
+    }
+    return false;
+  }
+
+  // -------------------------------------------------------------------------
+  // High-level query methods
+  // -------------------------------------------------------------------------
+
+  var api = {
+
+    // --- URL construction methods (preferred) ---
+
+    // Extract the session ID from the current page URL
+    extractSessionId: function () {
+      return extractSessionId();
+    },
+
+    // Decode the current page's search query
+    decodeCurrentQuery: function () {
+      return decodeCurrentQuery();
+    },
+
+    // Build a search URL for a simple assertion failure query
+    buildFailureQueryUrl: function (sessionId, assertionMessage) {
+      var query = buildQuery({
+        sessionId: sessionId,
+        conditions: [
+          { field: "assertion.message", op: "contains", value: assertionMessage },
+          { field: "assertion.status", op: "matches", value: "failing" },
+        ],
+      });
+      return buildSearchUrl(query);
+    },
+
+    // Build a search URL with a NOT PRECEDED BY temporal filter
+    buildNotPrecededByUrl: function (sessionId, assertionMessage, precededByField, precededByValue) {
+      var query = buildQuery({
+        sessionId: sessionId,
+        conditions: [
+          { field: "assertion.message", op: "contains", value: assertionMessage },
+          { field: "assertion.status", op: "matches", value: "failing" },
+        ],
+        temporalType: "not_preceded_by",
+        temporalConditions: [
+          { field: precededByField, op: "contains", value: precededByValue },
+        ],
+      });
+      return buildSearchUrl(query);
+    },
+
+    // Build a search URL with a NOT FOLLOWED BY temporal filter
+    buildNotFollowedByUrl: function (sessionId, assertionMessage, followedByField, followedByValue) {
+      var query = buildQuery({
+        sessionId: sessionId,
+        conditions: [
+          { field: "assertion.message", op: "contains", value: assertionMessage },
+          { field: "assertion.status", op: "matches", value: "failing" },
+        ],
+        temporalType: "not_followed_by",
+        temporalConditions: [
+          { field: followedByField, op: "contains", value: followedByValue },
+        ],
+      });
+      return buildSearchUrl(query);
+    },
+
+    // Build a fully custom query URL
+    buildCustomQueryUrl: function (options) {
+      var query = buildQuery(options);
+      return buildSearchUrl(query);
+    },
+
+    // Low-level: build a query JSON (for inspection before encoding)
+    buildQuery: function (options) {
+      return buildQuery(options);
+    },
+
+    // Low-level: encode a query JSON to a URL parameter value
+    encodeQuery: function (query) {
+      return encodeQuery(query);
+    },
+
+    // --- UI interaction methods (fallback) ---
+
+    // Set the value in a query textarea by index (0 = first row, etc.)
+    setQueryValue: function (index, value) {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      var textareas = getQueryTextareas();
+      if (index >= textareas.length) {
+        return { error: "textarea index " + index + " out of range, have " + textareas.length };
+      }
+      setTextareaValue(textareas[index], value);
+      return { ok: true, index: index, value: value };
+    },
+
+    // Execute the search and wait for results
+    search: async function (timeoutMs) {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      clickSearch();
+      await wait(1000);
+      return waitForResults(timeoutMs || 60000);
+    },
+
+    // Get the current result count
+    getResultCount: function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      return { count: getResultCount() };
+    },
+
+    // Read search results
+    readResults: function (limit) {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      return readResults(limit);
+    },
+
+    // Add a "Preceded by" temporal block
+    addPrecededBy: function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      return { ok: clickPrecededBy() };
+    },
+
+    // Add a "Followed by" temporal block
+    addFollowedBy: function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      return { ok: clickFollowedBy() };
+    },
+
+    // Switch the temporal mode to "Not preceded by"
+    switchToNotPrecededBy: async function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      var result = await switchToNotPrecededBy();
+      return { ok: result };
+    },
+
+    // Switch the temporal mode to "Not followed by"
+    switchToNotFollowedBy: async function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      var result = await switchToNotFollowedBy();
+      return { ok: result };
+    },
+
+    // Click the + AND button to add another condition row
+    addAndRow: function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      var addBtn = findClickableByText("+ AND");
+      if (!addBtn) addBtn = findClickableByText("AND");
+      return { ok: addBtn ? click(addBtn) : false };
+    },
+
+    // Switch to Map view
+    showMap: function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      var mapTab = findClickableByText("Map");
+      return { ok: mapTab ? click(mapTab) : false };
+    },
+
+    // Switch to List view
+    showList: function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      var listTab = findClickableByText("List");
+      return { ok: listTab ? click(listTab) : false };
+    },
+
+    // Click Reset to clear the query
+    reset: function () {
+      var error = requireLogsExplorer();
+      if (error) return error;
+      var resetBtn = findClickableByText("Reset");
+      return { ok: resetBtn ? click(resetBtn) : false };
+    },
+
+    // Get page info
+    pageInfo: function () {
+      return {
+        isLogsExplorer: isLogsExplorerPage(),
+        url: window.location.href,
+        sessionId: extractSessionId(),
+        resultCount: getResultCount(),
+        textareaCount: getQueryTextareas().length,
+        fieldSelectorCount: getFieldSelectors().length,
+      };
+    },
+  };
+
+  // -------------------------------------------------------------------------
+  // Register
+  // -------------------------------------------------------------------------
+
+  window.__antithesisQueryLogs = api;
+
+  return {
+    ok: true,
+    version: VERSION,
+    description: "Antithesis Logs Explorer runtime",
+    methods: Object.keys(api),
+  };
+})();
diff --git a/.claude/skills/antithesis-query-logs/assets/build-url.py b/.claude/skills/antithesis-query-logs/assets/build-url.py
new file mode 100755
index 0000000..c75b1f5
--- /dev/null
+++ b/.claude/skills/antithesis-query-logs/assets/build-url.py
@@ -0,0 +1,508 @@
+#!/usr/bin/env python3
+"""Build Antithesis Logs Explorer search URLs.
+
+Pure URL construction — no browser or DOM needed. The output is a URL you can
+hand to `agent-browser open` (or paste into a browser) to land on the Logs
+Explorer with the query pre-populated.
+
+Subcommands:
+  failure         A simple "assertion.message contains X AND assertion.status
+                  matches failing" query.
+  not-preceded-by Cascade-elimination query: X failures that are NOT preceded
+                  by Y.
+  not-followed-by Like above, but for X failures NOT followed by Y.
+  custom          Arbitrary query from a JSON spec (stdin or --spec-file).
+  decode          Decode a Logs Explorer URL back to the underlying query JSON.
+
+The encoded URL format is `v5v`, where the JSON shape is
+documented in references/query-builder.md. See `--help` on each subcommand for
+its specific flags.
+
+Examples:
+  build-url.py failure \\
+      --session-id abc...-54-5 \\
+      --property "node - kill" \\
+      --tenant orbitinghail.antithesis.com
+
+  build-url.py not-preceded-by \\
+      --session-id abc...-54-5 \\
+      --property "downstream check" \\
+      --pre-field assertion.message \\
+      --pre-value "upstream fault" \\
+      --tenant orbitinghail.antithesis.com
+
+  build-url.py decode --url 'https://...antithesis.com/search?search=v5v...'
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import sys
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+
+TEMPORAL_MAP = {
+    "preceded_by": {"y": "preceding", "negate": False},
+    "not_preceded_by": {"y": "preceding", "negate": True},
+    "followed_by": {"y": "following", "negate": False},
+    "not_followed_by": {"y": "following", "negate": True},
+}
+
+
+def cond(field: str, operator: str, value: str, case_sensitive: bool = False) -> dict:
+    return {"c": bool(case_sensitive), "f": field, "o": operator, "v": value}
+
+
+def cond_group(conditions: list, join_op: str = "or") -> dict:
+    return {"h": conditions, "o": join_op}
+
+
+def row_group(groups: list, join_op: str = "and") -> dict:
+    return {"r": {"h": groups, "o": join_op}}
+
+
+def build_query(
+    *,
+    session_id: str,
+    conditions: list[dict],
+    temporal_type: str | None = None,
+    temporal_conditions: list[dict] | None = None,
+) -> dict:
+    """Build a Logs Explorer query JSON object.
+
+    `conditions` and `temporal_conditions` are each a list of
+    {"field": ..., "op": ..., "value": ...} dicts. `temporal_type` is one of
+    "preceded_by" / "not_preceded_by" / "followed_by" / "not_followed_by"
+    (or None / "none" for no temporal block).
+    """
+    if not session_id:
+        raise ValueError("session_id is required")
+    if not conditions:
+        raise ValueError(
+            "conditions is required and must be a non-empty list of "
+            "{field, op, value} dicts. (Did you mean to pass 'conditions' "
+            "instead of 'rows'?)"
+        )
+
+    def to_group(c: dict) -> dict:
+        for key in ("field", "op", "value"):
+            if key not in c:
+                raise ValueError(f"condition missing required key '{key}': {c!r}")
+        return cond_group([cond(c["field"], c["op"], c["value"])])
+
+    main_groups = [to_group(c) for c in conditions]
+    query: dict[str, Any] = {
+        "q": {
+            "n": {**row_group(main_groups), "t": {"g": False, "m": ""}, "y": "none"},
+        },
+        "s": session_id,
+    }
+
+    if temporal_type and temporal_type != "none":
+        if not temporal_conditions:
+            raise ValueError(f"temporal_type={temporal_type!r} requires temporal_conditions")
+        if temporal_type not in TEMPORAL_MAP:
+            raise ValueError(
+                f"unknown temporal_type {temporal_type!r}; expected one of "
+                + ", ".join(sorted(TEMPORAL_MAP))
+            )
+        mapping = TEMPORAL_MAP[temporal_type]
+        temporal_groups = [to_group(c) for c in temporal_conditions]
+        query["q"]["p"] = {
+            **row_group(temporal_groups),
+            "t": {"g": mapping["negate"], "m": ""},
+            "y": mapping["y"],
+        }
+
+    return query
+
+
+def encode_query(query: dict) -> str:
+    """Encode a query JSON object into the v5v+base64 search param value."""
+    raw = json.dumps(query, separators=(",", ":")).encode("utf-8")
+    b64 = base64.b64encode(raw).decode("ascii").rstrip("=")
+    return "v5v" + b64
+
+
+def build_search_url(query: dict, tenant: str) -> str:
+    """Build a full Logs Explorer URL for the given query and tenant host."""
+    if not tenant:
+        raise ValueError("tenant is required (e.g. 'orbitinghail.antithesis.com')")
+    host = tenant
+    if host.startswith("http://") or host.startswith("https://"):
+        host = urlparse(host).netloc
+    return f"https://{host}/search?search={encode_query(query)}"
+
+
+def decode_url(url: str) -> dict:
+    """Decode a Logs Explorer URL back to its query JSON."""
+    parsed = urlparse(url)
+    qs = parse_qs(parsed.query)
+    search = (qs.get("search") or [None])[0]
+    if not search:
+        raise ValueError(f"no `search` query parameter found in URL: {url!r}")
+    if not search.startswith("v5v"):
+        raise ValueError(f"search param does not start with 'v5v' (got {search[:4]!r})")
+    b64 = search[3:]
+    pad = "=" * (-len(b64) % 4)
+    return json.loads(base64.b64decode(b64 + pad))
+
+
+# -----------------------------------------------------------------------------
+# CLI
+# -----------------------------------------------------------------------------
+
+
+def cmd_failure(args: argparse.Namespace) -> str:
+    query = build_query(
+        session_id=args.session_id,
+        conditions=[
+            {"field": "assertion.message", "op": "contains", "value": args.property},
+            {"field": "assertion.status", "op": "matches", "value": "failing"},
+        ],
+    )
+    return build_search_url(query, args.tenant)
+
+
+def cmd_not_preceded_by(args: argparse.Namespace) -> str:
+    query = build_query(
+        session_id=args.session_id,
+        conditions=[
+            {"field": "assertion.message", "op": "contains", "value": args.property},
+            {"field": "assertion.status", "op": "matches", "value": "failing"},
+        ],
+        temporal_type="not_preceded_by",
+        temporal_conditions=[
+            {"field": args.pre_field, "op": "contains", "value": args.pre_value},
+        ],
+    )
+    return build_search_url(query, args.tenant)
+
+
+def cmd_not_followed_by(args: argparse.Namespace) -> str:
+    query = build_query(
+        session_id=args.session_id,
+        conditions=[
+            {"field": "assertion.message", "op": "contains", "value": args.property},
+            {"field": "assertion.status", "op": "matches", "value": "failing"},
+        ],
+        temporal_type="not_followed_by",
+        temporal_conditions=[
+            {"field": args.post_field, "op": "contains", "value": args.post_value},
+        ],
+    )
+    return build_search_url(query, args.tenant)
+
+
+def cmd_custom(args: argparse.Namespace) -> str:
+    if args.spec_file:
+        with open(args.spec_file) as f:
+            spec = json.load(f)
+    else:
+        spec = json.load(sys.stdin)
+    query = build_query(
+        session_id=spec.get("sessionId") or spec.get("session_id"),
+        conditions=spec.get("conditions") or [],
+        temporal_type=spec.get("temporalType") or spec.get("temporal_type"),
+        temporal_conditions=(spec.get("temporalConditions") or spec.get("temporal_conditions")),
+    )
+    tenant = args.tenant or spec.get("tenant")
+    return build_search_url(query, tenant)
+
+
+def cmd_decode(args: argparse.Namespace) -> str:
+    decoded = decode_url(args.url)
+    return json.dumps(decoded, indent=2)
+
+
+def cmd_encode(args: argparse.Namespace) -> str:
+    """Encode a raw query JSON (shape: {q: {...}, s: ...}) into a search URL.
+
+    Useful when you already have a full query (e.g. captured from the UI's
+    own search URL or decoded from another search) and want to re-encode it
+    against a different tenant or just rebuild the URL.
+    """
+    if args.spec_file:
+        with open(args.spec_file) as f:
+            query = json.load(f)
+    else:
+        query = json.load(sys.stdin)
+    if not isinstance(query, dict) or "q" not in query or "s" not in query:
+        raise ValueError("encode expects a raw query JSON with top-level 'q' and 's' keys")
+    return build_search_url(query, args.tenant)
+
+
+def main(argv: list[str] | None = None) -> int:
+    # --test is a top-level flag (no subcommand). Handle it before argparse
+    # demands a subcommand.
+    if argv is None:
+        argv = sys.argv[1:]
+    if argv == ["--test"]:
+        return run_tests()
+
+    parser = argparse.ArgumentParser(
+        prog="build-url.py",
+        description="Build Antithesis Logs Explorer search URLs (pure, no browser).",
+    )
+    sub = parser.add_subparsers(dest="cmd", required=True)
+
+    p = sub.add_parser("failure", help="simple failure query for one property")
+    p.add_argument("--session-id", required=True)
+    p.add_argument("--property", required=True, help="assertion.message value")
+    p.add_argument("--tenant", required=True, help="e.g. orbitinghail.antithesis.com")
+    p.set_defaults(func=cmd_failure)
+
+    p = sub.add_parser(
+        "not-preceded-by",
+        help="failures of X that are NOT preceded by event Y",
+    )
+    p.add_argument("--session-id", required=True)
+    p.add_argument("--property", required=True, help="main assertion.message value")
+    p.add_argument("--pre-field", required=True, help="e.g. assertion.message")
+    p.add_argument("--pre-value", required=True, help="preceding event value")
+    p.add_argument("--tenant", required=True)
+    p.set_defaults(func=cmd_not_preceded_by)
+
+    p = sub.add_parser(
+        "not-followed-by",
+        help="failures of X that are NOT followed by event Y",
+    )
+    p.add_argument("--session-id", required=True)
+    p.add_argument("--property", required=True)
+    p.add_argument("--post-field", required=True)
+    p.add_argument("--post-value", required=True)
+    p.add_argument("--tenant", required=True)
+    p.set_defaults(func=cmd_not_followed_by)
+
+    p = sub.add_parser(
+        "custom",
+        help="arbitrary query from a JSON spec (stdin or --spec-file)",
+    )
+    p.add_argument("--spec-file", help="path to JSON spec; defaults to stdin")
+    p.add_argument(
+        "--tenant",
+        help="override the tenant from the spec",
+    )
+    p.set_defaults(func=cmd_custom)
+
+    p = sub.add_parser("decode", help="decode a Logs Explorer URL back to JSON")
+    p.add_argument("--url", required=True)
+    p.set_defaults(func=cmd_decode)
+
+    p = sub.add_parser(
+        "encode",
+        help="encode a raw query JSON (with q+s keys) into a Logs Explorer URL",
+    )
+    p.add_argument("--spec-file", help="path to JSON spec; defaults to stdin")
+    p.add_argument("--tenant", required=True)
+    p.set_defaults(func=cmd_encode)
+
+    args = parser.parse_args(argv)
+    try:
+        output = args.func(args)
+    except ValueError as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 2
+    print(output)
+    return 0
+
+
+# -----------------------------------------------------------------------------
+# Tests
+# -----------------------------------------------------------------------------
+
+# A known-good URL captured from the Antithesis platform (orbitinghail tenant,
+# 2026-05-21). Used to lock the encoded format against silent drift.
+KNOWN_GOOD_URL = (
+    "https://orbitinghail.antithesis.com/search?search=v5v"
+    "eyJxIjp7Im4iOnsiciI6eyJoIjpbeyJoIjpbeyJjIjpmYWxzZSwiZiI6ImFzc2VydGlvbi5tZX"
+    "NzYWdlIiwibyI6ImNvbnRhaW5zIiwidiI6Im5vZGUgLSBraWxsIn1dLCJvIjoib3IifSx7Im"
+    "giOlt7ImMiOmZhbHNlLCJmIjoiYXNzZXJ0aW9uLnN0YXR1cyIsIm8iOiJtYXRjaGVzIiwidi"
+    "I6ImZhaWxpbmcifV0sIm8iOiJvciJ9XSwibyI6ImFuZCJ9LCJ0Ijp7ImciOmZhbHNlLCJtIj"
+    "oiIn0sInkiOiJub25lIn19LCJzIjoiYjMxNTA0NDUzZjgyZWUwNDlhOTJkYjU2MTNhMjc3YT"
+    "ctNTQtNSJ9"
+)
+
+
+def run_tests() -> int:
+    failures = 0
+
+    def assert_eq(name, got, expected):
+        nonlocal failures
+        if got != expected:
+            print(f"FAIL: {name}")
+            print(f"  expected: {expected!r}")
+            print(f"  got:      {got!r}")
+            failures += 1
+        else:
+            print(f"  ok: {name}")
+
+    def assert_raises(name, exc_type, fn, *args, **kwargs):
+        nonlocal failures
+        try:
+            fn(*args, **kwargs)
+        except exc_type as e:
+            print(f"  ok: {name} (raised: {e})")
+            return
+        except Exception as e:
+            print(f"FAIL: {name} (raised {type(e).__name__}, expected {exc_type.__name__})")
+            failures += 1
+            return
+        print(f"FAIL: {name} (no exception raised)")
+        failures += 1
+
+    # -- Shape primitives --
+    assert_eq(
+        "cond default case-insensitive",
+        cond("f", "contains", "v"),
+        {"c": False, "f": "f", "o": "contains", "v": "v"},
+    )
+    assert_eq(
+        "cond case-sensitive",
+        cond("f", "matches", "v", case_sensitive=True),
+        {"c": True, "f": "f", "o": "matches", "v": "v"},
+    )
+    assert_eq(
+        "cond_group default join is 'or'",
+        cond_group([{"x": 1}]),
+        {"h": [{"x": 1}], "o": "or"},
+    )
+    assert_eq(
+        "row_group default join is 'and'",
+        row_group([{"x": 1}]),
+        {"r": {"h": [{"x": 1}], "o": "and"}},
+    )
+
+    # -- build_query (simple) --
+    simple = build_query(
+        session_id="sid-1",
+        conditions=[{"field": "assertion.message", "op": "contains", "value": "P"}],
+    )
+    assert_eq("simple: session id propagated", simple["s"], "sid-1")
+    assert_eq("simple: main type is 'none'", simple["q"]["n"]["y"], "none")
+    assert_eq("simple: no temporal block", "p" in simple["q"], False)
+    assert_eq(
+        "simple: condition shape",
+        simple["q"]["n"]["r"]["h"][0]["h"][0],
+        {"c": False, "f": "assertion.message", "o": "contains", "v": "P"},
+    )
+
+    # -- build_query (each temporal mode encodes y + t.g correctly) --
+    expectations = {
+        "preceded_by":     ("preceding", False),
+        "not_preceded_by": ("preceding", True),
+        "followed_by":     ("following", False),
+        "not_followed_by": ("following", True),
+    }
+    for mode, (expected_y, expected_negate) in expectations.items():
+        q = build_query(
+            session_id="sid-2",
+            conditions=[{"field": "a", "op": "contains", "value": "x"}],
+            temporal_type=mode,
+            temporal_conditions=[{"field": "b", "op": "contains", "value": "y"}],
+        )
+        assert_eq(f"temporal {mode}: q.p.y", q["q"]["p"]["y"], expected_y)
+        assert_eq(f"temporal {mode}: q.p.t.g (negate)", q["q"]["p"]["t"]["g"], expected_negate)
+        # The main block stays "none" even when a temporal block is present.
+        assert_eq(f"temporal {mode}: q.n.y stays 'none'", q["q"]["n"]["y"], "none")
+
+    # -- Validation errors --
+    assert_raises(
+        "missing session_id",
+        ValueError,
+        build_query,
+        session_id="",
+        conditions=[{"field": "a", "op": "contains", "value": "b"}],
+    )
+    assert_raises(
+        "missing conditions",
+        ValueError,
+        build_query,
+        session_id="s",
+        conditions=[],
+    )
+    assert_raises(
+        "condition missing field key",
+        ValueError,
+        build_query,
+        session_id="s",
+        conditions=[{"op": "contains", "value": "b"}],
+    )
+    assert_raises(
+        "temporal_type without temporal_conditions",
+        ValueError,
+        build_query,
+        session_id="s",
+        conditions=[{"field": "a", "op": "contains", "value": "b"}],
+        temporal_type="not_preceded_by",
+    )
+    assert_raises(
+        "unknown temporal_type",
+        ValueError,
+        build_query,
+        session_id="s",
+        conditions=[{"field": "a", "op": "contains", "value": "b"}],
+        temporal_type="sometime_around",
+        temporal_conditions=[{"field": "c", "op": "contains", "value": "d"}],
+    )
+
+    # -- encode_query / build_search_url --
+    encoded = encode_query(simple)
+    assert_eq("encode_query has v5v prefix", encoded.startswith("v5v"), True)
+    assert_eq("encode_query has no trailing padding", encoded.endswith("="), False)
+
+    url = build_search_url(simple, "orbitinghail.antithesis.com")
+    assert_eq(
+        "build_search_url scheme + host",
+        url.startswith("https://orbitinghail.antithesis.com/search?search=v5v"),
+        True,
+    )
+    assert_eq(
+        "build_search_url strips scheme from tenant",
+        build_search_url(simple, "https://orbitinghail.antithesis.com"),
+        url,
+    )
+    assert_raises("missing tenant", ValueError, build_search_url, simple, "")
+
+    # -- decode_url --
+    decoded = decode_url(KNOWN_GOOD_URL)
+    assert_eq("decode_url: session id", decoded["s"], "b31504453f82ee049a92db5613a277a7-54-5")
+    assert_eq(
+        "decode_url: first field",
+        decoded["q"]["n"]["r"]["h"][0]["h"][0]["f"],
+        "assertion.message",
+    )
+    assert_raises("decode_url: no search param", ValueError, decode_url, "https://x.example.com/")
+    assert_raises(
+        "decode_url: missing v5v prefix",
+        ValueError,
+        decode_url,
+        "https://x.example.com/search?search=notv5v",
+    )
+
+    # -- Round-trip: known-good URL → decode → encode → identical URL --
+    roundtrip = build_search_url(decoded, "orbitinghail.antithesis.com")
+    assert_eq("known-good URL round-trip stable", roundtrip, KNOWN_GOOD_URL)
+
+    # -- Cross-check: build_query reproduces the known-good JSON exactly --
+    rebuilt = build_query(
+        session_id="b31504453f82ee049a92db5613a277a7-54-5",
+        conditions=[
+            {"field": "assertion.message", "op": "contains", "value": "node - kill"},
+            {"field": "assertion.status", "op": "matches", "value": "failing"},
+        ],
+    )
+    assert_eq("build_query matches platform output", rebuilt, decoded)
+
+    print()
+    if failures:
+        print(f"{failures} test(s) failed")
+        return 1
+    print("all tests passed")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/.claude/skills/antithesis-query-logs/references/map.md b/.claude/skills/antithesis-query-logs/references/map.md
new file mode 100644
index 0000000..40645f0
--- /dev/null
+++ b/.claude/skills/antithesis-query-logs/references/map.md
@@ -0,0 +1,44 @@
+# Multiverse Map
+
+## Overview
+
+The Map tab shows a tree visualization of all timelines in a test run.
+Gray lines represent timelines, which branch from parent timelines when
+Antithesis forks execution to explore different paths. Purple dots mark
+where the searched events occur.
+
+## Cluster Analysis
+
+Failures that appear on the same timeline branch likely share a common
+root cause. Failures on different branches are independent reproductions.
+
+**Key metric: independent cluster count.**
+
+- "8 failures in 2 clusters" — likely 2 distinct root causes
+- "8 failures in 8 clusters" — 8 independent reproductions, strong signal
+- "8 failures in 1 cluster" — potentially one root cause cascading through
+  forked timelines
+
+## Investigation Strategy
+
+1. Identify how many distinct clusters exist
+2. Pick one representative failure from each cluster
+3. Investigate each cluster's representative — failures within the same
+   cluster are likely very similar
+4. Different clusters may have different root causes
+
+## Accessing the Map
+
+After executing a search, click the `Map` tab near the results count.
+The map renders with the timeline tree and event dots.
+
+## DOM Structure
+
+The map tab is selectable via a tab element near the search results.
+The map itself renders as an interactive visualization — right-click
+for zoom and view options.
+
+Clicking a dot on the map opens the corresponding log viewer panel, same as
+clicking a result in the List view. From there you can use the
+`antithesis-triage` skill's log reading methods (see that skill's
+`references/logs.md`) to inspect the full timeline context around that event.
diff --git a/.claude/skills/antithesis-query-logs/references/query-builder.md b/.claude/skills/antithesis-query-logs/references/query-builder.md
new file mode 100644
index 0000000..6fe9bda
--- /dev/null
+++ b/.claude/skills/antithesis-query-logs/references/query-builder.md
@@ -0,0 +1,210 @@
+# Query Builder
+
+## Page Structure
+
+The Logs Explorer is at `https://{tenant}.antithesis.com/search?search={encoded_query}`.
+It is NOT the same as the per-example log viewer (which has `get_logs=true` in the URL).
+
+## What You Can Search For
+
+The Logs Explorer searches across all timelines in a run. There are three
+broad categories of searchable events:
+
+### 1. Property failures (assertion events)
+
+Search for Antithesis SDK assertion events by property name and status. Use
+this when investigating a specific property from a triage report.
+
+- **Field**: `assertion.message` — the property name passed to the SDK
+  assertion call (e.g., `always`, `sometimes`, `reachable`)
+- **Field**: `assertion.status` — `passing` or `failing`
+- **When to use**: Following up on a failing property from triage, counting
+  independent failures, cascade elimination via temporal queries
+
+### 2. Plain-text log output
+
+Search for strings in stdout/stderr output from any container. Use this when
+looking for error messages, warnings, or specific log lines that aren't
+structured assertions.
+
+- **Field**: `general.output_text` — matches against the raw text output
+  from processes
+- **When to use**: Searching for error messages (e.g., "connection refused",
+  "segmentation fault"), correlating failures with specific log output,
+  building temporal queries where the cascade source is a log message rather
+  than a property failure
+
+### 3. Structured event fields
+
+Search within structured metadata attached to events — the source process,
+container name, virtual time, or custom fields.
+
+- **Field**: `general.source` — the process that emitted the event (e.g.,
+  `python3.11`, `fault_injector`)
+- **Field**: `general.custom` — custom structured fields on events
+- **When to use**: Filtering by container or process, correlating with fault
+  injection events, narrowing results to a specific part of the system
+
+These categories can be combined in a single query using AND/OR connectors.
+For example, you can search for property failures that occur on a specific
+container, or plain-text error messages that are preceded by a fault injection
+event.
+
+## Field Names (Important)
+
+Field names are **singular**, not plural:
+
+| Field                 | Description                    | Operator   |
+| --------------------- | ------------------------------ | ---------- |
+| `assertion.message`   | Property/assertion name        | `contains` |
+| `assertion.status`    | `passing` or `failing`         | `matches`  |
+| `assertion.type`      | Assertion type                 | `contains` |
+| `assertion.function`  | Function name                  | `contains` |
+| `assertion.file`      | Source file                    | `contains` |
+| `general.output_text` | Log output text                | `contains` |
+| `general.source`      | Process that emitted the event | `contains` |
+| `general.vtime`       | Virtual time                   | varies     |
+| `general.moment`      | Moment                         | varies     |
+| `general.custom`      | Custom field                   | varies     |
+
+**Critical**: `assertion.status` requires the `matches` operator, NOT
+`contains`. Using `contains` for status will return no results.
+
+## Two Approaches: URL Construction vs UI Interaction
+
+### URL Construction (Preferred): build-url.py
+
+Prefer constructing the search URL with `assets/build-url.py` and handing
+it to `agent-browser open`. URLs are deterministic and never depend on the
+DOM, so this avoids the fragility of clicking dropdowns and targeting rows
+in the dynamic query builder.
+
+```bash
+# Simple failure query
+python3 assets/build-url.py failure \
+  --session-id "$SESSION_ID" \
+  --property "my-property-name" \
+  --tenant "{tenant}.antithesis.com"
+
+# Cascade-elimination (X failures NOT preceded by Y)
+python3 assets/build-url.py not-preceded-by \
+  --session-id "$SESSION_ID" \
+  --property "downstream-check" \
+  --pre-field assertion.message \
+  --pre-value "upstream-fault" \
+  --tenant "{tenant}.antithesis.com"
+
+# Independence check (X failures NOT followed by Y) — same shape
+python3 assets/build-url.py not-followed-by \
+  --session-id "$SESSION_ID" \
+  --property "X" \
+  --post-field assertion.message --post-value "Y" \
+  --tenant "{tenant}.antithesis.com"
+
+# Arbitrary query — see `custom --help` for the JSON spec
+echo '{"sessionId":"...","conditions":[{"field":"general.output_text","op":"contains","value":"oops"}]}' \
+  | python3 assets/build-url.py custom --tenant "{tenant}.antithesis.com"
+
+# Decode an existing URL (e.g. extract the sessionId from a report's
+# "Explore logs" link)
+python3 assets/build-url.py decode --url 'https://.../search?search=v5v...'
+python3 assets/build-url.py decode --url "$EXPLORE_LOGS_URL" | jq -r '.s'
+```
+
+`build-url.py --help` lists every subcommand and flag. The CLI exits
+non-zero with a clear message on missing fields, unknown temporal types,
+or malformed input.
+
+> **Format stability note**: The encoded URL format (`?search=v5v`)
+> was reverse-engineered from the Antithesis platform by inspecting live
+> URLs. It is not documented by Antithesis and may change. If URLs built
+> by `build-url.py` stop working after a platform update, fall back to
+> the UI interaction method below and update `build-url.py` to match.
+
+### UI Interaction (Fallback)
+
+If URL construction stops working — or you genuinely need to drive the
+page (e.g. to capture exactly what the platform produces for a given UI
+sequence) — interact with the DOM via the injected runtime
+(`assets/antithesis-query-logs.js`, methods on
+`window.__antithesisQueryLogs`).
+
+#### Query Builder Elements
+
+The query builder has rows of conditions connected by operators.
+
+##### Run Selector
+
+```
+div.select_container.event_search_run_selector
+```
+
+Shows the current run. Pre-filled when navigating from a triage report.
+
+##### Query Row
+
+Each row has three parts:
+
+1. **Field selector**: `div.select_container.query_select` (first one)
+   - Categories: `general`, `assertions`, `test templates`, `fault injector`
+     (the UI category label is plural "assertions", but field names are singular — `assertion.message`)
+   - Assertion fields: `message`, `type`, `status`, `function`, `file`
+   - General fields: `output_text`, `source`, `vtime`, `moment`, `custom`
+   - The `message` field under assertions maps to `assertion.message`
+   - The `status` field under assertions maps to `assertion.status`
+
+2. **Operator selector**: `div.select_container.query_select` (second one)
+   - `contains` — substring match
+   - `excludes` — negative substring match
+   - `regex` — regular expression match
+   - `matches` — exact match (required for `assertion.status`)
+
+3. **Value input**: `textarea.textarea_component`
+   - Enter the search text here
+
+##### Row Connectors
+
+Rows are connected by selectable options:
+
+- `AND` — both conditions must match on the same event
+- `OR` — either condition matches
+- `+ AND` — add another AND condition row (the button text is `+ AND`)
+
+These appear as labels/radio buttons between query rows.
+
+##### Adding Rows
+
+Click `+ AND` to add another condition row to the current query block.
+
+## Building a Query (UI Method)
+
+### Simple assertion search
+
+1. Click the field selector → choose `assertions` section → `message`
+2. Operator: `contains`
+3. Value: the property name (e.g., `data-integrity-after-restart`)
+4. Click `+ AND`
+5. Field: `assertions` → `status`
+6. Operator: `matches` (NOT `contains`)
+7. Value: `failing`
+8. Click the `Search` label/button
+
+### Search Button
+
+The Search button is a `label` element with text "Search". Click it to execute.
+
+```bash
+agent-browser --session "$SESSION" eval \
+  "Array.from(document.querySelectorAll('label')).find(l => l.textContent.trim() === 'Search').click()"
+```
+
+## Results
+
+After searching, result rows render inside `.event_search_results`. The
+match count is shown in a separate ``
+above the rows (e.g. "54,924 matching events"); when there are zero
+matches, "No matching events" appears inside `.event_search_results`
+instead. `window.__antithesisQueryLogs.getResultCount()` and `.search()`
+read both locations.
+
+Results can be viewed as `List` or `Map` (tabs near the results header).
diff --git a/.claude/skills/antithesis-query-logs/references/results.md b/.claude/skills/antithesis-query-logs/references/results.md
new file mode 100644
index 0000000..dc94268
--- /dev/null
+++ b/.claude/skills/antithesis-query-logs/references/results.md
@@ -0,0 +1,58 @@
+# Search Results
+
+## Result Count
+
+After a search completes, the count appears as:
+```
+Search results: N matching events
+```
+
+The loading state shows:
+```
+Loading results... N% done
+```
+
+Wait for loading to complete before reading results.
+
+## Result List
+
+Results appear in `.event_search_results` as rows with:
+- **Virtual time** (vtime) — the simulation timestamp
+- **Source** — which process/container emitted the event (e.g., `python3.11`)
+- **Container** — which container (e.g., `client1`)
+- **Event preview** — truncated assertion JSON `{antithesis_assert: ...}`
+
+## Expanding Event Details
+
+Each result row has a `{}` icon/button that expands to show the full
+assertion details JSON. This is the structured data attached to the assertion
+event — whatever key-value pairs the SUT's assertion call included as details.
+
+The expanded details are the most reliable way to see assertion context when
+log viewer output is truncated. Use this to correlate failures with specific
+detail values. If all failures share a common detail (e.g., a particular
+configuration mode, feature flag, or protocol version) that passing events
+do not, that detail is likely part of the root cause.
+
+## Clicking a Result
+
+Clicking a result row opens a log viewer panel on the right side showing:
+- The full timeline log centered on that event
+- Preceding and following events in the same timeline
+- Fault injection events
+- Other assertion events
+
+This log panel is the same as the per-example log viewer from triage. You
+can use the `antithesis-triage` skill's log reading methods
+(see that skill's `references/logs.md`) to programmatically read and search
+within this panel.
+This is the bridge between the Logs Explorer (which finds events across all
+timelines) and the triage log viewer (which gives deep context around a
+single event).
+
+## List vs Map Tabs
+
+Toggle between `List` (default) and `Map` views using the tabs near the
+result count:
+- **List**: Chronological event list with expandable details
+- **Map**: Multiverse timeline tree with failure dots (see map.md)
diff --git a/.claude/skills/antithesis-query-logs/references/temporal-queries.md b/.claude/skills/antithesis-query-logs/references/temporal-queries.md
new file mode 100644
index 0000000..c0de458
--- /dev/null
+++ b/.claude/skills/antithesis-query-logs/references/temporal-queries.md
@@ -0,0 +1,255 @@
+# Temporal Queries
+
+## Overview
+
+Temporal queries filter results based on whether another event occurs before
+or after the matched event in the same timeline. Uses include:
+
+- **Cascade elimination**: Are these failures independent, or are they
+  downstream consequences of an earlier failure?
+- **Correlation with earlier state**: Are all failures preceded by a
+  particular random selection or configuration choice made earlier in the
+  timeline (e.g., a software version, a feature flag, a startup mode)?
+- **Root cause hypothesis testing**: A component starts in a particular
+  state and much later something fails. Are all the failures in timelines
+  where that initial state was set? Use "preceded by" to confirm or
+  "not preceded by" to find counterexamples that disprove the hypothesis.
+- **Fault injection correlation**: Are failures preceded by a specific fault
+  event (network partition, disk error, thread pause)? If all failures
+  require a fault, the bug is less severe than one that happens without
+  injection. Use "NOT preceded by" any fault to find bugs that occur under
+  normal operation — those are the scariest.
+- **Container kill/restart correlation**: Container kills and restarts are
+  a common trigger for bugs. Check whether failures are preceded by a
+  container kill or restart event to determine if the bug is
+  crash-recovery-related.
+- **Error-handling path correlation**: Timeouts, retries, fallbacks, and
+  failovers are all candidates to check as precursors to a bug. These
+  error-handling paths are exercised rarely in production but frequently
+  under Antithesis fault injection, and bugs often hide in or just after
+  them.
+- **Recovery verification** (using "followed by"): After a failure or
+  disruption, was there a recovery event? Failures NOT followed by a
+  reconnection or healing event indicate liveness problems.
+- **Ordering violations**: Some operations are safe in one order but buggy
+  in another (A then B is fine, but B then A is a bug). Search for failures
+  of B that are "preceded by" A (should be safe) vs "not preceded by" A
+  (the dangerous ordering) to confirm the ordering dependency.
+- **Partitioning failures by root cause**: You have many failures of the
+  same property but suspect multiple distinct causes. Use "preceded by"
+  queries with different candidate precursors to split failures into subsets
+  and count each — this reveals how many distinct mechanisms produce the
+  same symptom.
+
+## Temporal Types
+
+The caller-facing temporal type names used in the runtime methods are:
+
+| Type                | Runtime value       | Meaning                                        |
+| ------------------- | ------------------- | ---------------------------------------------- |
+| None                | `"none"`            | Simple query, no temporal filter               |
+| Preceded by         | `"preceded_by"`     | Match events that ARE preceded by the condition |
+| Not preceded by     | `"not_preceded_by"` | Match events NOT preceded by the condition      |
+| Followed by         | `"followed_by"`     | Match events that ARE followed by the condition |
+| Not followed by     | `"not_followed_by"` | Match events NOT followed by the condition      |
+
+The runtime translates these to the platform's URL encoding automatically.
+In the URL JSON, the encoding is different:
+
+- `q.n.y` is always `"none"` — even for temporal queries
+- The temporal type lives on `q.p.y`: `"preceding"` or `"following"`
+- Negation is encoded in `q.p.t.g`: `true` = NOT, `false` = positive
+
+## URL Construction (Preferred)
+
+Build temporal queries by modifying the query JSON before encoding it into
+the search URL. See `query-builder.md` for the base JSON format.
+
+### JSON Format for Temporal Queries
+
+Set the `y` field on the main query block (`q.n`) to the temporal type, and
+add a `p` sibling key containing the temporal condition:
+
+```json
+{
+  "q": {
+    "n": {
+      "r": {
+        "h": [
+          {
+            "h": [
+              {
+                "c": false,
+                "f": "assertion.message",
+                "o": "contains",
+                "v": "data-integrity-after-restart"
+              }
+            ],
+            "o": "or"
+          },
+          {
+            "h": [
+              {
+                "c": false,
+                "f": "assertion.status",
+                "o": "matches",
+                "v": "failing"
+              }
+            ],
+            "o": "or"
+          }
+        ],
+        "o": "and"
+      },
+      "t": {
+        "g": false,
+        "m": ""
+      },
+      "y": "none"
+    },
+    "p": {
+      "r": {
+        "h": [
+          {
+            "h": [
+              {
+                "c": false,
+                "f": "general.output_text",
+                "o": "contains",
+                "v": "connection refused"
+              }
+            ],
+            "o": "or"
+          }
+        ],
+        "o": "and"
+      },
+      "t": {
+        "g": true,
+        "m": ""
+      },
+      "y": "preceding"
+    }
+  },
+  "s": "{session_id}"
+}
+```
+
+### Key Differences from Simple Query
+
+| Field      | Simple query | Temporal query              |
+| ---------- | ------------ | --------------------------- |
+| `q.n.y`    | `"none"`     | `"none"` (always)           |
+| `q.p`      | absent       | temporal condition block     |
+| `q.p.y`    | absent       | `"preceding"` or `"following"` |
+| `q.p.t.g`  | absent       | `true` = NOT, `false` = positive |
+
+The `q.p` block uses the same `r.h` structure as the main `q.n.r` block —
+it is a full query with its own field/operator/value conditions.
+
+### Temporal Window
+
+The `t` field controls the look-back or look-ahead window:
+
+- `t.g = false`, `t.m = ""` — any event in the timeline (no window limit)
+- `t.g = true`, `t.m = "30"` — within 30 seconds
+
+## Building a Cascade Elimination Query
+
+### Workflow
+
+1. **First query (baseline)**: Build a simple query for the target failures.
+   Count the results.
+
+2. **Second query (with temporal filter)**: Build the same query but with
+   `y: "not_preceded_by"` and a `p` block matching the suspected cascade
+   source. Count the results.
+
+3. **Compare counts**:
+   - Same count → failures are independent, not cascades
+   - Count drops → the difference is cascade failures
+   - Count drops to zero → all failures are cascades
+
+### Example: Ruling out cascades from an upstream failure
+
+**Hypothesis**: `data-integrity-after-restart` failures might be cascades
+from an earlier `connection-pool-exhausted` failure in the same timeline.
+
+**Query 1** (baseline):
+- `assertion.message` contains `data-integrity-after-restart`
+- AND `assertion.status` matches `failing`
+- Result: 53 matching events
+
+**Query 2** (temporal filter):
+- Same WHERE clause
+- NOT PRECEDED BY: `assertion.message` contains `connection-pool-exhausted`
+- Result: 53 matching events (same count)
+
+**Conclusion**: All 53 failures are independent — none were preceded by the
+upstream failure. The two properties fail for different reasons.
+
+### Example: Confirming a root cause correlation
+
+**Query 1**: `assertion.message` contains `order-total-never-negative`
+AND `assertion.status` matches `failing` → 8 results
+
+**Query 2**: Same, but PRECEDED BY `general.output_text` contains
+`failover to secondary` → 8 results (all match)
+
+**Conclusion**: All 8 failures were preceded by a failover event. The
+bug is in the failover path, not in normal operation.
+
+## UI Interaction (Fallback)
+
+### Temporal Connectors
+
+Below the main query block, two buttons appear:
+
+- **Preceded by** — adds a temporal clause above the WHERE block
+- **Followed by** — adds a temporal clause below the WHERE block
+
+Clicking either adds a new query block with its own field/operator/value rows.
+
+### Preceded By / Not Preceded By
+
+After clicking "Preceded by", a new block appears above WHERE with:
+
+1. A **dropdown** that allows switching between:
+   - `Preceded by` — match events that ARE preceded by the condition
+   - `Not preceded by` — match events that are NOT preceded by the condition
+
+2. A **time window**: `any event within` `Look-back` `seconds`
+   - Default is "any event within" which means anywhere earlier in the timeline
+   - Can be narrowed to a specific time window
+
+3. **Query rows** — same field/operator/value format as the main WHERE block
+
+### Building via UI
+
+1. Click "Preceded by" to add the temporal block
+2. In the temporal block's dropdown, select "Not preceded by"
+3. Field: `assertion.message` contains the cascade source property name
+4. AND: `assertion.status` matches `failing`
+5. In the WHERE block:
+   - Field: `assertion.message` contains the target property name
+   - AND: `assertion.status` matches `failing`
+6. Click Search
+
+### DOM Selectors for Temporal Controls
+
+The "Preceded by" and "Followed by" are clickable labels:
+```
+label elements with text "Preceded by" and "Followed by"
+```
+
+The dropdown to switch between "Preceded by" and "Not preceded by":
+```
+After clicking Preceded by, a dropdown appears with options:
+- "Preceded by"
+- "Not preceded by"
+```
+
+The time window controls:
+```
+"any event within" dropdown + "Look-back" input + "seconds" label
+```
diff --git a/.claude/skills/antithesis-research/SKILL.md b/.claude/skills/antithesis-research/SKILL.md
new file mode 100644
index 0000000..145c489
--- /dev/null
+++ b/.claude/skills/antithesis-research/SKILL.md
@@ -0,0 +1,187 @@
+---
+name: antithesis-research
+description: >
+  Analyze a codebase to figure out how it should be tested with Antithesis:
+  map the system, identify failure-prone areas and testable properties, and
+  produce the research artifacts needed for workload and environment planning.
+metadata:
+  version: "2026-07-14 1f59c97"
+---
+
+# Antithesis Research
+
+**Skill version:** `2026-07-14 1f59c97`
+
+## Purpose and Goal
+
+Research a target system and produce scratchbook artifacts that unblock the rest of the workflow. Success means:
+
+- `antithesis/scratchbook/sut-analysis.md` captures architecture, state, concurrency, and failure-prone areas
+- `antithesis/scratchbook/property-catalog.md` lists concrete, testable properties with priorities
+- `antithesis/scratchbook/deployment-topology.md` describes the minimal useful container topology
+- `antithesis/scratchbook/properties/{slug}.md` captures per-property evidence trails and context
+- `antithesis/scratchbook/property-relationships.md` maps suspected clusters and connections between properties
+- `antithesis/scratchbook/evaluation/synthesis.md` records categorized evaluation findings and actions taken
+
+## Prerequisites and Scoping
+
+At the start of every research run, ask the user about external references:
+
+> Before I start, are there docs, design notes, related repos, issue trackers, or anything else outside this directory tree that I should also consult? "Just this directory" is fine.
+
+Capture the answer verbatim. Each external reference becomes a `path` (or URL) plus a brief `why` note. The orchestrator (this skill) holds the answer for the full duration of the run and threads it as input to every reference workflow that produces a top-level artifact: `sut-discovery.md`, `sut-analysis.md`, `property-discovery.md`, `deployment-topology.md`, `property-evaluation.md`. Each of those references' agent-instruction sections accepts the list as input. The list is also recorded in the provenance frontmatter of every top-level artifact (see `references/scratchbook-setup.md`). Any agent that receives the external references treats each as a lead to validate, not a fact — see `references/validating-claims.md`.
+
+Beyond the scope question, ask only for blockers or scoping decisions you cannot infer safely:
+
+- The repo or codebase location, if it is not already clear
+- Which subsystem or component matters most, if the work is narrower than the whole repo
+- Known incidents, closed bugs, or specific failure modes worth targeting
+
+If scratchbook artifacts already exist, treat them as inputs and extend them instead of rewriting them unless the user asks for a fresh pass.
+
+## Definitions and Concepts
+
+- **SUT:** System under test.
+- **Workload:** A synthetic workload designed to exercise the target system
+- **Safety property (correctness):** A bad thing never happens
+- **Liveness property (progress):** A good thing eventually happens
+- **Reachability property:** A code path or behavior is reachable or unreachable
+- **Test Template:** A directory of test commands located at `/opt/antithesis/test/v1/{name}/`. There may be more than one test template. Each timeline executes commands from a single test template. Files or subdirectories prefixed with `helper_` are ignored by Antithesis.
+- **Test Command:** An executable file in a Test Template with a valid prefix. Valid prefixes: `parallel_driver_`, `singleton_driver_`, `serial_driver_`, `first_`, `eventually_`, `finally_`, `anytime_`
+- **Timeline:** A single linear execution of the target system and workload. Antithesis runs many timelines in parallel and branches them to search for interesting behaviors.
+
+## Documentation Grounding
+
+Use the `antithesis-documentation` skill to ground Antithesis-specific terminology and implementation advice.
+
+- Properties and assertions: `https://antithesis.com/docs/concepts/properties_assertions/assertions.md`
+- Sometimes assertions: `https://antithesis.com/docs/concepts/properties_assertions/sometimes_assertions.md`
+- Define test properties: `https://antithesis.com/docs/reference/sdk/define_test_properties.md`
+- SDK runtime modes and production behavior: `https://antithesis.com/docs/reference/sdk.md`
+- Optimize for testing: `https://antithesis.com/docs/best_practices/optimizing.md`
+
+## Reference Files
+
+| Reference                           | When to read                                        |
+| ----------------------------------- | --------------------------------------------------- |
+| `references/scratchbook-setup.md`      | Always — read first to initialize the workspace     |
+| `references/sut-discovery.md`       | Discovering system characteristics through structured attention focuses |
+| `references/sut-analysis.md`        | General methodology for analyzing the codebase and understanding components |
+| `references/property-discovery.md`  | Discovering properties through structured attention focuses |
+| `references/property-catalog.md`    | Format and methodology for documenting properties   |
+| `references/validating-claims.md`   | How to treat claims from docs and issues — validate before building on them |
+| `references/faults.md`              | Understanding fault types, quiet periods, and process fault availability |
+| `references/deployment-topology.md` | Designing the container topology for Antithesis     |
+| `references/property-evaluation.md` | Evaluating the property catalog as a portfolio       |
+
+## Recommended Workflows
+
+### Full research pass (new project)
+
+1. Read `references/scratchbook-setup.md`
+2. Read `references/sut-discovery.md` and `references/sut-analysis.md`
+3. Analyze the system using the ensemble or single-agent workflow from `references/sut-discovery.md`
+4. Scan the codebase for existing Antithesis SDK assertions. Search for imports
+   of the Antithesis SDK and calls to assertion functions (`assert_always!`,
+   `assert_sometimes!`, `assert_reachable!`, `assert_unreachable!`, or their
+   non-macro equivalents). For each assertion found, record the file path, line
+   number, assertion type, and message string. Write the results to
+   `antithesis/scratchbook/existing-assertions.md`. Begin the file with
+   provenance frontmatter per `references/scratchbook-setup.md`. If no
+   assertions are found, write the file with a note confirming the codebase has
+   no existing instrumentation.
+5. Read `references/property-discovery.md` and `references/property-catalog.md`
+6. Discover properties using the ensemble or single-agent workflow from `references/property-discovery.md`
+7. Read `references/deployment-topology.md`
+8. Design the deployment topology and write it to `antithesis/scratchbook/deployment-topology.md`
+9. Read `references/property-evaluation.md`
+10. Evaluate the property catalog using the ensemble or single-agent workflow from `references/property-evaluation.md`
+11. Address evaluation findings: apply refinements, fill gaps, escalate biases to the user
+12. Write or update all remaining findings in the scratchbook under `antithesis/scratchbook/`
+
+### Targeted property research
+
+1. Read `references/sut-discovery.md` and `references/sut-analysis.md` if the system model is missing or stale
+2. Read `references/property-discovery.md` and `references/property-catalog.md`
+3. Discover properties using the ensemble or single-agent workflow from `references/property-discovery.md`
+4. Turn claimed guarantees into explicit properties. For incidents and bug reports, confirm the reported defect is a real system defect before making it a property (see `references/validating-claims.md`). Choose the Antithesis assertion type that matches each one
+5. Update `antithesis/scratchbook/property-catalog.md` and record assumptions or open questions
+6. Write evidence files for new properties to `antithesis/scratchbook/properties/{slug}.md`
+7. Update `antithesis/scratchbook/property-relationships.md` with any new clusters or connections
+8. Read `references/property-evaluation.md`
+9. Evaluate the updated property catalog using the ensemble or single-agent workflow from `references/property-evaluation.md`
+10. Address evaluation findings: apply refinements, fill gaps, escalate biases to the user
+
+### Property expansion (after triage)
+
+1. Read `references/property-discovery.md` and `references/property-catalog.md`
+2. Review triage findings from the `antithesis-triage` skill
+3. Use the attention focuses from `references/property-discovery.md` to look for new properties inspired by triage findings
+4. Update the relevant files in the scratchbook
+5. Write evidence files for new properties to `antithesis/scratchbook/properties/{slug}.md`
+6. Update `antithesis/scratchbook/property-relationships.md` with any new clusters or connections
+7. If the expansion is substantial (a new category of properties or more than 3
+   new properties), read `references/property-evaluation.md` and evaluate the
+   updated catalog. Small expansions of 1-3 properties in existing categories
+   do not require a full evaluation pass.
+
+## General Guidance
+
+- Prefer specific, checkable guarantees over vague goals like "test failover"
+- If the system's docs or comments claim a guarantee, make it a property to test — the property verifies the claim; don't state in the analysis that the guarantee holds
+- A bug report in an issue is a lead, not a fact. Confirm the reported defect is a real system defect before building anything on it (see `references/validating-claims.md`)
+- Record not just the invariant, but why the chosen Antithesis assertion type is the right semantic fit for that property
+- Use `Sometimes(cond)` for liveness or non-trivial semantic states, not for invariants that must hold on every evaluation and not as a substitute for `Reachable(...)`
+- Identify where surgical SUT-side assertions would give materially better search guidance than workload-only checks, especially for rare, dangerous, timing-sensitive, or externally invisible states
+- Remember that Antithesis SDK assertions do not crash the program on failure; they are intended to be safe in production code and usually become low-overhead fallbacks or no-ops outside Antithesis
+- Focus on timing-sensitive, concurrency-sensitive, and partial-failure scenarios where Antithesis is strongest
+- Keep the deployment topology minimal; every extra container expands state space
+- Write down assumptions and open questions in the scratchbook instead of keeping them implicit
+- If only part of the research is requested, still update the relevant scratchbook files and note what remains undone
+
+## Output
+
+- `antithesis/scratchbook/sut-analysis.md`
+- `antithesis/scratchbook/existing-assertions.md`
+- `antithesis/scratchbook/property-catalog.md`
+- `antithesis/scratchbook/deployment-topology.md`
+- `antithesis/scratchbook/property-relationships.md`
+- `antithesis/scratchbook/properties/{slug}.md` (one per cataloged property)
+- `antithesis/scratchbook/evaluation/synthesis.md`
+- `antithesis/scratchbook/evaluation/{lens}.md` (one per evaluation lens)
+
+All top-level scratchbook artifacts (everything in the list above except per-property evidence files) include provenance frontmatter recording `sut_path`, `commit`, `updated`, and `external_references`. Per-property evidence files inherit context from `property-catalog.md` and do not need their own frontmatter. See `references/scratchbook-setup.md`.
+
+These outputs should be concrete enough for the `antithesis-setup` skill and the `antithesis-workload` skill to use directly.
+
+## Self-Review
+
+Before declaring this skill complete, review your work against the criteria below. If your agent supports spawning sub-agents, create a new agent with fresh context to perform this review — give it the path to this skill file and have it read all output artifacts. A fresh-context reviewer catches blind spots that in-context review misses. If your agent does not support sub-agents, perform the review yourself: re-read the success criteria at the top of this file, then systematically check each item below against your actual output.
+
+Review criteria:
+
+- `antithesis/scratchbook/sut-analysis.md` exists and covers architecture, state management, concurrency model, and failure-prone areas
+- `antithesis/scratchbook/existing-assertions.md` exists and lists all Antithesis SDK assertions found in the codebase (or explicitly states none were found)
+- Evidence files correctly distinguish between instrumentation that already exists in the codebase, instrumentation that is partially present, and instrumentation that is missing — no evidence file suggests adding an assertion that is already there
+- `antithesis/scratchbook/property-catalog.md` exists, has provenance frontmatter per `references/scratchbook-setup.md`, and lists concrete, testable properties — not vague goals like "test failover"
+- Every top-level artifact begins with provenance frontmatter per `references/scratchbook-setup.md`
+- The `external_references` list reflects the user's stated scope answer; entries that were referenced have a `why` note explaining their use
+- The scope question was asked at the start of the run, and the user's answer was passed to every sub-agent that performed analysis
+- Each property has a descriptive kebab-case slug as its canonical ID
+- Each property has a priority and a rationale for its chosen Antithesis assertion type (`Always`, `Sometimes`, `Reachable`, etc.)
+- Properties that need internal branch guidance or replay anchors call out likely SUT-side instrumentation points, not just workload-visible checks
+- `antithesis/scratchbook/deployment-topology.md` exists and describes a minimal container topology — every container is justified
+- Every cataloged property has a corresponding evidence file at `antithesis/scratchbook/properties/{slug}.md` that captures the evidence trail, relevant code paths, and key observations
+- Each property's catalog entry includes an Open Questions list that mirrors the unresolved questions from its evidence file (short summaries; full context stays in the evidence file). Resolved questions are removed; remaining questions are tagged per `references/property-catalog.md` ("Open Questions Conventions").
+- Each `(partial: ...)` or `(needs human input)` tag is backed by an investigation log entry in the evidence file (see `references/property-catalog.md` "Investigation Log") that records what was examined, what was found, and what wasn't. Untagged (not yet investigated) questions don't require a log entry. Unresolved questions that need human input are an acceptable outcome — they must be visible in the catalog overview, not buried in evidence files.
+- Properties invalidated by investigation are marked in the catalog with the reason
+- `antithesis/scratchbook/property-relationships.md` exists and groups related properties into clusters with brief notes on suspected connections and dominance
+- Every property slug referenced in `property-relationships.md` corresponds to a property in the catalog
+- Properties focus on timing-sensitive, concurrency-sensitive, and partial-failure scenarios where Antithesis is strongest
+- Claimed guarantees from docs or comments are represented as properties to test, and the analysis does not state any guarantee as an established fact
+- Any statement that a specific defect exists, taken from a bug report, cites primary evidence that the defect is real and rules out the reporter's environment or config — not just a link to the issue or a matching symptom
+- Property evaluation was performed: `antithesis/scratchbook/evaluation/synthesis.md` exists with categorized findings
+- Evaluation refinements have been applied to the catalog
+- Evaluation gaps have been filled via targeted discovery, and the resulting properties are in the catalog with evidence files
+- Evaluation biases (if any) have been presented to the user with supporting evidence
+- The outputs are concrete enough for `antithesis-setup` and `antithesis-workload` to use directly — no ambiguous steps or missing details
diff --git a/.claude/skills/antithesis-research/references/deployment-topology.md b/.claude/skills/antithesis-research/references/deployment-topology.md
new file mode 100644
index 0000000..1acc7c8
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/deployment-topology.md
@@ -0,0 +1,109 @@
+# Deployment Topology
+
+## Goal
+
+Design the simplest container topology that covers the code you want to verify. Every extra container adds complexity and slows down Antithesis's exploration. Include only what is necessary.
+
+## External References
+
+If the user named external references during scoping (see `SKILL.md` "Prerequisites and Scoping"), check them for documented production topology, deployment diagrams, sizing guidance, or topology-relevant design notes before designing the minimal Antithesis topology. The orchestrator passes the list as input.
+
+## Three Component Groups
+
+### Dependencies
+
+External services the SUT needs to function. Find Docker images that run, simulate, or mock them.
+
+Examples:
+
+- Postgres → official `postgres` image
+- AWS S3 → Minio
+- Redis → official `redis` image
+- Kafka → Redpanda or official Kafka image
+
+Prefer lightweight alternatives that are protocol-compatible. The dependency doesn't need to be production-grade — it needs to speak the right protocol.
+
+### Services
+
+The processes that make up the SUT. Only package what we're actually testing. Split services between containers based on how they run in production.
+
+- Find existing Dockerfiles in the repo and evaluate whether they can be reused or adapted.
+- Plan new layered Dockerfiles if needed (base image → build stage → runtime stage).
+- Each service container should run a single process.
+
+### Clients
+
+Containers that run the test workload. These contain the test commands that exercise the SUT.
+
+A typical client container:
+
+1. Emits `setup_complete` to signal readiness
+2. Sleeps or otherwise stays alive, letting Antithesis run test commands from the test template
+
+This readiness signaling happens before any test-template command runs. Do not plan for a `first_` command to emit `setup_complete`; `first_` commands are timeline actions that start only after Antithesis has already received the readiness signal.
+
+The client container image includes the test template directory at `/opt/antithesis/test/v1/{name}/`. Helper files or directories kept inside that template should use a `helper_` prefix so Antithesis ignores them.
+
+## Topology Examples
+
+### Simple client-server
+
+```text
++--------------------+      +--------------------+      +--------------------+
+| workload client    | ---> | app server         | ---> | database           |
+| (test driver)      | <--- | (SUT entrypoint)   | <--- | (dependency)       |
++--------------------+      +--------------------+      +--------------------+
+                requests/responses           queries/results
+```
+
+### Complex distributed system
+
+```text
+                        +--------------------+
+                        | stateful client    |
+                        | (workload driver)  |
+                        +---------+----------+
+                                  |
+                                  v
+ +--------------------+   +--------------------+   +--------------------+
+ | consensus node A   |<->| consensus node B   |<->| consensus node C   |
+ +---------+----------+   +---------+----------+   +---------+----------+
+           |                        |                        |
+           +-----------+------------+------------+-----------+
+                       |                         |
+                       v                         v
+             +--------------------+    +--------------------+
+             | minio s3 storage   |    | redis cache        |
+             +--------------------+    +--------------------+
+```
+
+## Simplicity Principle
+
+The less you deploy to Antithesis while still covering the code you want to verify, the better performance and bug-finding. Every container adds state space. Every network link adds interleavings. Keep it minimal.
+
+## Replica Decisions
+
+- If testing consensus (e.g., Raft), run at least 3 replicas. Consensus algorithms need a quorum to exercise interesting behaviors.
+- If testing a concurrent data structure, a simple client/server may suffice.
+- If testing replication, run enough replicas to have a meaningful replication topology (typically 2-3).
+
+## SDK Selection
+
+Determine which Antithesis Language SDKs you need based on the languages used in the SUT and workload. Refer to the SDK reference and Instrumentation overview docs (available via the `antithesis-documentation` skill).
+
+The workload client always needs the SDK to emit assertions. The SUT services may also need instrumentation for deeper coverage.
+
+## Document the Plan
+
+Write to `antithesis/scratchbook/deployment-topology.md`. Begin with provenance frontmatter per `references/scratchbook-setup.md`. For each component, document:
+
+- Container name
+- Image source (existing Dockerfile, official image, or new Dockerfile to create)
+- Role (dependency, service, or client)
+- What it runs
+- Network connections to other containers
+- Replica count (if more than one)
+
+## Output
+
+Write the topology plan to `antithesis/scratchbook/deployment-topology.md`, including provenance frontmatter (see `references/scratchbook-setup.md`).
diff --git a/.claude/skills/antithesis-research/references/faults.md b/.claude/skills/antithesis-research/references/faults.md
new file mode 100644
index 0000000..f49fa22
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/faults.md
@@ -0,0 +1,178 @@
+# Faults
+
+## Goal
+
+Understand the types of faults Antithesis can inject so that SUT analysis and
+property discovery are grounded in what the platform actually does to your system.
+
+## Fault Categories
+
+Antithesis injects faults at the **container level** — everything in a container
+shares the same fate. Placing two services in one container means they will never
+see faults in their communication with each other. Separate containers can be
+faulted independently.
+
+### Network Faults
+
+Network faults disrupt packet delivery between containers. Incoming and outgoing
+packets are treated independently, so all network faults are potentially
+**asymmetric** (one direction disrupted while the other works).
+
+Baseline latency
+: Always-on simulated network delay and a low baseline packet drop rate. Not a
+fault event — this is the environment's normal behavior.
+
+Congestion
+: Periods of elevated packet loss and latency drawn from a high-latency
+distribution. Packets are often reordered.
+
+Basic network faults
+: Two containers temporarily lose their connection, simulating transient
+equipment failure within a rack or availability zone. Can slow, drop, or
+suspend packet delivery on a per-stream basis.
+
+Partitions
+: Containers are split into groups. Communication within a group is normal;
+communication between groups is disrupted. Configurable by frequency,
+symmetry, group count, and duration. Other network faults (e.g. latency)
+remain active during partitions.
+
+Bad nodes
+: One or more containers lose the ability to communicate. Affects inbound,
+outbound, or both directions. May affect multiple containers simultaneously.
+
+For partitions and bad-node faults, disruption can take several forms: all
+packets dropped (complete disconnection), packets held until the fault expires
+then delivered at once, or packets delivered with added latency and
+probabilistic drops.
+
+### Node Faults
+
+Node hang
+: A container becomes totally unresponsive for a temporary period, then resumes.
+
+Node throttling
+: A container's CPU is limited, making it slow and less responsive as if under
+heavy load. Surfaces bugs that load testing or DoS attacks would find.
+
+Node termination
+: A container is either gracefully shut down or crash-killed, then restored
+after a random delay. Restarted containers may get new IP addresses and may
+lose non-durable filesystem state. **Disabled by default** — see
+[Process Fault Availability](#process-fault-availability).
+
+### Clock Faults
+
+Clock jitter
+: The system clock jumps forward or backward, simulating daylight savings, leap
+seconds, or timezone changes. Affects all containers equally (exception to the
+per-container rule). Low-level intrinsics like `__rdtsc()` are unaffected.
+
+### Other Faults
+
+Thread pausing
+: Individual threads are paused briefly, causing unexpected interleavings.
+Requires [instrumentation](https://antithesis.com/docs/reference/instrumentation/coverage_instrumentation.md).
+
+CPU modulation
+: The simulated processor clock speed and instruction-level performance
+characteristics change, altering the execution order of concurrent threads.
+Can trigger some of the same bugs as thread pausing without requiring
+instrumentation.
+
+Custom faults
+: User-defined scripts or programs invoked by the fault injector at random
+intervals. Common uses: toggling admin config, triggering compaction/GC,
+forking background processes. Custom faults can use the Antithesis SDK to draw
+random numbers for probabilistic behavior.
+
+## Fault Availability
+
+Not all fault types are enabled by default. The set of enabled faults depends on
+the tenant configuration and the webhook used to launch runs. For example, node
+termination (kill/stop) and clock faults are commonly disabled in default
+configurations.
+
+If a property or test depends on a specific fault type being active (e.g. crash
+recovery requires node termination, clock-sensitive logic requires clock jitter),
+flag it as a requirement in the property catalog and confirm with the user that
+the fault is enabled for their tenant. Properties that depend on disabled faults
+will not be exercised and may show as unfound or vacuously passing.
+
+Customers can adjust fault availability by contacting Antithesis support.
+Alternatively, some fault scenarios can be approximated via test commands, custom
+faults, or other mechanisms available in the environment.
+
+## Requesting Quiet Periods with `ANTITHESIS_STOP_FAULTS`
+
+Antithesis provides a global **stop-faults API** to temporarily pause all fault
+injection across all containers. This gives the entire system a recovery window
+to reach a stable state.
+
+### How It Works
+
+The environment automatically injects an `ANTITHESIS_STOP_FAULTS` binary into
+every container and sets the corresponding environment variable. To request a
+quiet period:
+
+```bash
+[ "${ANTITHESIS_STOP_FAULTS}" ] && "${ANTITHESIS_STOP_FAULTS}" 
+```
+
+The guard clause (`[ "${ANTITHESIS_STOP_FAULTS}" ]`) lets the script run
+harmlessly outside the Antithesis environment (e.g. during local testing).
+
+When invoked:
+
+1. **All faults stop** — network faults are restored, node faults are cleared,
+   and no new faults are injected for the requested duration.
+2. **Containers are restored** — killed or stopped containers are restarted, but
+   they take some time to become fully operational.
+3. **Faults resume automatically** after the requested duration elapses.
+4. **Overlapping requests merge** — if multiple calls overlap, the quiet period
+   extends to cover the largest interval.
+
+### Using Quiet Periods for Liveness Checks
+
+The `eventually_` test command prefix runs with faults paused, but Antithesis
+does not resume testing on that branch afterward — it is a terminal check. When
+you need a **mid-run liveness check** that allows testing to continue afterward,
+use `ANTITHESIS_STOP_FAULTS` instead.
+
+A typical pattern in a driver command:
+
+1. Run workload operations while faults are active.
+2. Call `ANTITHESIS_STOP_FAULTS` with enough seconds for the system to recover.
+3. Wait for the system to stabilize (poll for health, retry reads, etc.).
+4. Assert liveness properties (e.g. "all replicas eventually converge",
+   "queued work is eventually processed").
+5. Resume the workload — faults will restart automatically after the quiet period.
+
+This is especially useful during **rolling operations** (upgrades, config
+changes, migrations) where you want to verify the system recovers to a healthy
+state at each step before continuing, without ending the test branch.
+
+### When to Use Which
+
+| Mechanism                | Faults paused? | Test continues after? | Use case                                    |
+| ------------------------ | -------------- | --------------------- | ------------------------------------------- |
+| `eventually_` command    | Yes            | No (terminal branch)  | Final liveness validation                   |
+| `finally_` command       | Yes            | No (terminal branch)  | Post-driver invariant checks                |
+| `ANTITHESIS_STOP_FAULTS` | Yes            | Yes (faults resume)   | Mid-run recovery checks, rolling operations |
+
+## Relevance to Research
+
+When discovering properties, consider:
+
+- **Safety properties** should hold even while faults are active. Network
+  partitions, node hangs, clock jumps, and throttling are the default tools
+  Antithesis uses to stress safety guarantees.
+- **Liveness properties** typically need a quiet period to verify. Note whether
+  the property can be checked with an `eventually_` command or needs
+  `ANTITHESIS_STOP_FAULTS` for mid-run verification.
+- **Recovery properties** (crash recovery, restart correctness) require node
+  termination faults, which must be explicitly enabled. Flag these in the
+  property catalog.
+- **Custom faults** may be needed for application-level fault scenarios (e.g.
+  injecting bad config, triggering leader election via admin API). Note these
+  during SUT analysis.
diff --git a/.claude/skills/antithesis-research/references/property-catalog.md b/.claude/skills/antithesis-research/references/property-catalog.md
new file mode 100644
index 0000000..63fbed0
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/property-catalog.md
@@ -0,0 +1,197 @@
+# Property Catalog
+
+## Provenance
+
+The property catalog is a snapshot — it reflects the codebase at the time of analysis. Begin the output file with the standard provenance frontmatter defined in `references/scratchbook-setup.md`. When updating an existing catalog (adding properties, revising after triage), refresh `commit` and `updated` to reflect the current codebase state.
+
+## Property Types
+
+- **Safety (correctness):** A bad thing never happens.
+- **Liveness (progress):** A good thing eventually happens.
+- **Reachability:** A code path is (or isn't) reached.
+
+## Core Methodology
+
+Establish an invariant. Run a workload. Let Antithesis inject faults and explore code paths. Assert the invariant still holds.
+
+This cycle applies to every property: define what should be true, exercise the system, and check that it remains true under adversarial conditions.
+
+## Every Property Must Be Specific and Checkable
+
+Not "test failover" but "acknowledged writes survive leader failover" with a concrete assertion. Vague properties can't be implemented and can't fail — which means they can't find bugs.
+
+Ask: "What exact condition would I check in code to verify this?" If you can't answer that, the property isn't specific enough.
+
+## Cross-Reference Closed Issues as Regression Targets
+
+A recently-fixed bug is a great Antithesis test because the fix may not cover all edge cases. Confirm the real root cause and mechanism from the fix and the discussion before turning a closed issue into a property — the report's own description of the cause is a lead, not the confirmed mechanism, and some issues close as "not a bug" or user error and describe no system defect at all (see `references/validating-claims.md`). For each closed bug in the target area:
+
+- What was the root cause?
+- What timing or fault condition triggered it?
+- Could a similar but slightly different condition bypass the fix?
+- Turn these into explicit properties in the catalog.
+
+## Property Catalog Format
+
+Each property has a unique **slug** — a descriptive, kebab-case identifier (e.g., `acked-writes-survive`, `no-split-brain`). The slug is the canonical ID for the property, used as the catalog heading, the evidence filename (`properties/{slug}.md`), and the identifier in the property relationships file.
+
+For each property, document:
+
+```
+### [slug] — [Property Name]
+
+| | |
+|---|---|
+| **Type** | Safety / Liveness / Reachability |
+| **Property** | One sentence: what guarantee are we testing? |
+| **Invariant** | What Antithesis SDK assertion(s)? Be specific about what is checked and how. Include why that assertion type matches the property semantics. |
+| **Antithesis Angle** | How does fault injection interact with this? What timing/interleaving does it explore? |
+| **Why It Matters** | Real-world impact. Link to issues if applicable. |
+
+**Open Questions:**
+
+- Bullet list of unresolved questions about this property, or "None" if no questions remain. See "Open Questions Conventions" below for state tags and rules.
+```
+
+For every property in the catalog, write a corresponding evidence file at `properties/{slug}.md`. See the "Evidence Files" section below.
+
+## Honest Summaries
+
+A property's catalog entry — its prose fields *and* its Open Questions list together — is the honest summary of what the analysis knows. The fields describe what the property is and why; the Open Questions list describes what's uncertain about it. Both parts do work. Don't overload one to compensate for the other.
+
+In particular, don't write a firm claim in **Property**, **Invariant**, **Antithesis Angle**, or **Why It Matters** that the Open Questions list contradicts. That's internally inconsistent, and a reviewer who notices will distrust the rest of the catalog. The fix is to write the prose at the level of generality the analysis actually supports — not to stuff parenthetical hedges into the field. If a key term in the property's prose has an unresolved meaning, the term can stay; the question goes in the Open Questions list and the reader knows to treat the term parametrically.
+
+The assertion type in **Invariant** (`Always`, `Sometimes(cond)`, etc.) is the one place where there's no parametric option: it's a discrete commitment. If you can't decide between two assertion types, the property isn't ready for the catalog — pick one (using "Choosing the Right Antithesis Assertion" below) or split into two properties.
+
+**Example.** The analysis identified an open question about what "acknowledged" means in this codebase but pinned down everything else about the property.
+
+Bad — the catalog entry hides the uncertainty. The reviewer sees a firm claim and assumes a specific definition of "acknowledged" that the analysis didn't actually verify:
+
+```
+| **Property** | Acknowledged writes survive leader failover. |
+
+**Open Questions:**
+
+- None
+```
+
+Good — same prose, but the Open Questions list does the honest work. The reader sees that "acknowledged" is contested and treats the property's claim parametrically:
+
+```
+| **Property** | Acknowledged writes survive leader failover. |
+
+**Open Questions:**
+
+- What does "acknowledged" mean in this codebase — fsync'd to leader, or replicated to quorum? `(needs human input)`
+```
+
+The Property prose is identical in both. The honest summary is the entry as a whole, including the list.
+
+## Open Questions Conventions
+
+The Open Questions list under each property gives a reviewer scanning the catalog at-a-glance signal for which properties have unresolved uncertainty. It complements the evidence file; it does not replace it.
+
+- **Summary, not verbatim:** each bullet is a short summary of one unresolved question. The full "why it matters / what changes depending on the answer" context lives in the evidence file (see "Evidence Files" below). The list points at the evidence file; it doesn't duplicate it.
+- **State tags:** tags describe how much investigation has happened — not what kind of question it is. Different kinds of questions deserve different follow-up (some need code reading, some need a stakeholder), but the list tracks status only.
+  - No tag — not yet investigated. This is the default at discovery output time.
+  - `(partial: )` — investigation produced some findings but the core question remains open. Example: `(partial: confirmed write path uses fsync, replication path still unclear)`.
+  - `(needs human input)` — investigation completed and the question can't be resolved without information from a human (project owner, domain expert, etc.). The evidence file's investigation log records what was examined and what's missing.
+- **Exhaust investigation before tagging `(needs human input)`:** the tag means "I tried, the code/docs don't have the answer." The self-review criterion in `SKILL.md` requires the evidence file's investigation log to back this up. Skipping investigation and immediately tagging gets caught at review.
+- **Resolved questions drop out:** once a question is answered, remove it from the list. If the answer changes the property — different invariant, different assertion type, different scope — update the affected catalog fields accordingly. Even if the prose was already written parametrically (per "Honest Summaries"), the resolution often makes specific terms more precise; revise the prose if the new precision changes what the field claims.
+- **Priority is independent of open questions:** a high-priority property may have many open questions; a low-priority property may have none. Open Questions surface uncertainty about a property's claims; they don't lower its value. Don't deprioritize a property because its Open Questions list is busy.
+- **Per-property vs. catalog-wide:** this list is per-property — it holds questions about a specific property's claims. Catalog-wide questions (about the analysis itself, the deployment topology, the scope of testing) belong under the file-level `Open Questions` heading per `references/scratchbook-setup.md`.
+
+Example of a populated list (one bullet per state — untagged, partial, needs-human-input):
+
+```
+**Open Questions:**
+
+- Does the snapshot path retry on a partial write, or surface the error?
+- Is the post-restart index always monotonic? `(partial: confirmed for clean shutdown, crash path unclear)`
+- What does "acknowledged" mean in this codebase — fsync'd to leader, or replicated to quorum? `(needs human input)`
+```
+
+## Choosing the Right Antithesis Assertion
+
+Antithesis assertions are not traditional crash-on-failure assertions. Failed Antithesis assertions do not terminate the program; they report property outcomes and guide exploration. That makes them safe to place in workload code and, when useful, production code paths. Outside Antithesis, SDKs are designed for minimal overhead and many languages support build-time or runtime disable modes.
+
+For every property, explicitly note which assertion type should implement it and why:
+
+- **`Always`**: Use for safety and correctness invariants that must hold every time the check runs. Example: "an acknowledged write is never lost once committed."
+- **`AlwaysOrUnreachable`**: Use for invariants on optional, rare, or workload-dependent paths where "never executed" is acceptable but any execution must satisfy the invariant. Example: "if a follower serves a stale-read fast path, its read timestamp is still safe."
+- **`Sometimes(cond)`**: Use for liveness/progress properties and for non-trivial semantic states that should become true at least once during a run. The condition must itself be meaningful. Example: "leader election completes" or "a degraded-but-recoverable mode is observed." Do not use `Sometimes` for invariants that must hold on every evaluation, and treat `Sometimes(true, ...)` as a smell that should instead be `Reachable(...)`.
+- **`Reachable`**: Use for pure reachability goals where the fact of hitting a code path, branch result, or outcome helps Antithesis focus on interesting areas of the code to search. Prefer meaningful outcomes over broad path-entry markers. Example: "snapshot completed successfully" or "the compaction path emitted its no-op result."
+- **`Unreachable`**: Use for impossible states and critical failure paths that must never be observed. Example: "the data-corruption guardrail trips" or "an internal panic recovery path is entered."
+
+`Sometimes(cond)` assertions deserve extra attention in the catalog. They are more informative than generic line coverage because they can describe interesting situations, not just locations, and Antithesis uses them as exploration hints and replay checkpoints. Add them when you want to confirm the system reaches rare, tricky, or high-value semantic states.
+
+When a state is dangerous, timing-sensitive, hard to observe externally, or useful as a branch or replay anchor, record that the property likely needs SUT-side instrumentation rather than workload-only checks.
+
+Every planned assertion message must be unique and specific. Do not model a broad property by reusing one assertion message at multiple unrelated callsites; split it into distinct properties or explicit sub-properties instead.
+
+If a property seems to fit multiple assertion types, prefer the one that matches the real guarantee:
+
+- "Must always or never happen" -> `Always` or `Unreachable`
+- "Must eventually happen at least once because a meaningful condition becomes true" -> `Sometimes(cond)`
+- "Must eventually hit this line" -> `Reachable`
+- "Must hold whenever this optional path runs" -> `AlwaysOrUnreachable`
+
+## How Many Properties
+
+Aim for at least 15 properties. Enough to be comprehensive, not so many that the catalog is overwhelming. Group related properties together. Start with high impact properties.
+
+## Organizing the Catalog
+
+Organize properties into categories based on system architecture. Common categories:
+
+- Data integrity under faults
+- Read correctness
+- Control plane behavior
+- Configuration changes under load
+- Edge cases and boundary conditions
+
+Each category should have a brief description explaining what area of the system it covers and why it matters.
+
+## Evidence Files
+
+Every property in the catalog must have a corresponding evidence file at `antithesis/scratchbook/properties/{slug}.md`. These files capture the context and reasoning behind each property — information that would otherwise be lost when findings are compressed into catalog entries.
+
+Evidence files are freeform markdown. Write whatever context would help a future reader understand why this property was identified and what code is involved. Typical content includes:
+
+- What led to identifying this property (code patterns, bug history, claimed guarantees, documentation)
+- Specific files and functions involved in the property
+- What goes wrong if the property is violated
+- Anything that would be expensive to rediscover (timing windows, configuration dependencies, subtle interactions)
+
+When you encounter questions you can't answer within the scope of discovery, include them in the evidence file — but don't just state the question. Capture why the question matters and what changes depending on the answer. For example, don't write "Does `fsmRestore()` enforce monotonicity?" Write "Does `fsmRestore()` enforce monotonicity? If not, a crash between `db.Swap()` and `fsmIdx.Store()` could cause index regression, which means the invariant statement needs to account for the restore path. If yes, the property is stronger than stated and the instrumentation can be simpler." A downstream investigator who reads this knows what to look for and how to update the property based on what they find.
+
+The goal is to preserve what you already know from your analysis. Don't do additional deep research for the evidence file — capture what you learned during discovery.
+
+### Investigation Log
+
+When an open question is investigated (per "Investigate Open Questions" in `references/property-discovery.md`), record what was examined under an `### Investigation Log` heading at the bottom of the evidence file. Use one `####` sub-heading per question (verbatim or close-paraphrase of the question text), with the body recording what was examined, found, and not found:
+
+```
+### Investigation Log
+
+#### What does "acknowledged" mean — fsync'd to leader, or replicated to quorum?
+
+- Examined: `internal/replication/leader.go`, `internal/wal/sync.go`, README "Durability" section, issue #482.
+- Found: leader writes to local WAL with fsync before responding; replication is async by default.
+- Not found: whether `--sync-replicate` flag changes the response point. The flag is referenced in tests but its semantics aren't documented.
+- Conclusion: tagged `(needs human input)` — owner needs to confirm intended behavior under `--sync-replicate`.
+```
+
+A few rules for the log:
+
+- **Persistence.** Log entries stay in the evidence file after the question resolves — they are audit trail, not working memory. They prove the criterion was met. A future reviewer or a re-investigation pass can read them.
+- **Multi-pass investigation.** If a question is re-investigated (e.g., during evaluation refinement or post-triage iteration), append a new section under the same `####` heading rather than rewriting. Date or context the new entry so the sequence is clear.
+- **Required vs. illustrative fields.** The Examined / Found / Not found / Conclusion structure is the recommended template; deviate when the question doesn't fit. The reviewer's bar is "I can tell what was tried and what was missing," not "I can tick four labeled fields."
+
+This makes the "attempted" check in `SKILL.md` self-review verifiable: a reviewer can confirm that each `(partial: ...)` or `(needs human input)` tag has a real investigation behind it.
+
+## Output
+
+Write the catalog to `antithesis/scratchbook/property-catalog.md`. Include provenance frontmatter per `references/scratchbook-setup.md`.
+Write per-property evidence files to `antithesis/scratchbook/properties/{slug}.md`.
+Write the property relationships to `antithesis/scratchbook/property-relationships.md`.
diff --git a/.claude/skills/antithesis-research/references/property-discovery.md b/.claude/skills/antithesis-research/references/property-discovery.md
new file mode 100644
index 0000000..9963036
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/property-discovery.md
@@ -0,0 +1,320 @@
+# Property Discovery
+
+## Goal
+
+Systematically discover testable properties by examining the system from multiple
+independent perspectives. Each perspective (attention focus) looks at the same
+codebase but prioritizes different failure modes, producing properties that a
+single unstructured pass would miss.
+
+This process runs after the SUT analysis is complete and produces candidate
+properties in the standard catalog format defined in `references/property-catalog.md`.
+
+## Prerequisites
+
+- `antithesis/scratchbook/sut-analysis.md` exists and is current
+- `antithesis/scratchbook/existing-assertions.md` exists (scan results — may
+  indicate no existing instrumentation, but must be present before agents are
+  spawned)
+
+## Attention Focuses
+
+Each focus defines a lens for examining the system. Every focus should produce
+properties in the standard catalog format.
+
+### 1. Data Integrity
+
+Consistency guarantees, invariants on stored state, corruption paths. Look for:
+write ordering assumptions, transaction boundaries, constraint enforcement, data
+loss scenarios under concurrent access.
+
+### 2. Concurrency
+
+Races, atomicity, ordering dependencies, shared mutable state. Look for: lock
+ordering, TOCTOU patterns, concurrent container access, thread-safety assumptions
+in documentation vs. implementation.
+
+### 3. Failure Recovery
+
+Crash recovery, restart correctness, partial failure handling, retry semantics.
+Look for: incomplete operations that survive restarts, recovery procedures that
+assume clean state, retry storms, idempotency gaps in recovery paths.
+Also note candidate SUT instrumentation points for retry outcomes, recovery
+subphases, and dangerous internal fallback paths that workloads may not observe
+cleanly.
+
+### 4. Protocol Contracts
+
+API guarantees, message ordering, schema validation, backward compatibility. Look
+for: documented API guarantees that aren't enforced, ordering assumptions between
+services, response codes that mask errors.
+
+### 5. Resource Boundaries
+
+Exhaustion, leaks, backpressure, capacity limits, queue depths. Look for:
+unbounded queues, missing backpressure, file descriptor leaks, memory growth under
+sustained load, connection pool exhaustion.
+
+### 6. Security Boundaries
+
+Authentication/authorization invariants, privilege escalation paths, input
+validation. Look for: operations that bypass auth checks, role escalation through
+sequences of valid operations, injection points.
+
+### 7. Distributed Coordination
+
+Consensus, leader election, split-brain, network partition behavior, replication.
+Look for: split-brain recovery, replication lag visibility, quorum loss handling,
+stale leader commands. May yield no properties for single-process systems.
+Also note candidate SUT instrumentation points for redirect emission, leader
+handoff, stepdown, and leadership-loss retry internals.
+
+### 8. Lifecycle Transitions
+
+Startup, shutdown, upgrade, migration, initialization ordering. Look for: requests
+during startup before subsystems are ready, graceful shutdown that drops in-flight
+work, migration steps that assume no concurrent traffic.
+Also note candidate SUT instrumentation points for distinct lifecycle outcomes,
+not just broad "entered startup/shutdown path" markers.
+
+### 9. Idempotency and Replay
+
+Duplicate handling, at-least-once delivery, reprocessing safety. Look for: side
+effects on retry, message deduplication gaps, replay of already-applied operations.
+
+### 10. Version Compatibility
+
+Behavioral differences across client/server version combinations, backward/forward
+compatibility guarantees, deprecation boundary correctness. Look for: changed
+serialization formats, removed/renamed fields, different default values across
+versions, protocol negotiation failures.
+
+### 11. Wildcard
+
+The other 10 focuses have fixed lenses — they see what their descriptions tell
+them to look for. This focus has no fixed lens. Its job is to find what they will
+all miss: the weird, the novel, the property that doesn't fit any category but
+matters anyway. These are not principles — this focus is deliberately unconstrained.
+
+1. **Know the covered territory.** The orchestrator provides a one-line summary of
+   each other focus. Understand their territory. Your job starts where theirs ends.
+
+2. **Look for the non-obvious.** Properties that emerge from behaviors nobody would
+   think to check. The system works, the standard invariants hold, but something
+   about the interaction pattern is surprising. Trust that instinct and turn it into
+   a testable property.
+
+3. **Find missing concepts.** Not missing data integrity checks (Focus 1 handles
+   that) or missing concurrency properties (Focus 2 handles that) — but missing
+   *angles*. A failure scenario nobody considers. A guarantee the system implicitly
+   provides but never claims. A property that only matters under a specific
+   combination of conditions that no single focus would construct.
+
+4. **Cross-cut.** Properties that live at the intersection of multiple focus areas.
+   Where failure recovery interacts with idempotency in ways neither focus captures
+   alone. Where resource exhaustion triggers a protocol violation. Where a lifecycle
+   transition exposes a concurrency bug that only manifests during version upgrades.
+
+5. **Question the frame.** The other focuses look for properties within their
+   domains. You can question whether the domains are complete. Is there an entire
+   class of failure that the focus set doesn't cover? Is there a property the system
+   *should* have but nobody would think to assert because it seems too obvious?
+
+6. **Report what's odd.** If a code path feels like it would produce an interesting
+   property but you can't fully specify the invariant, describe the intuition. A
+   vague signal from the wildcard is still signal.
+
+## Ensemble Mode
+
+If your environment supports spawning sub-agents, run property discovery as an
+ensemble — one agent per attention focus, in parallel.
+
+### Agent Instructions
+
+Spawn one agent per focus. Each agent receives:
+
+- The contents of `antithesis/scratchbook/sut-analysis.md`
+- The property catalog format from `references/property-catalog.md`
+- One attention focus (its full description and "look for" guidance from above)
+- The path to `antithesis/scratchbook/existing-assertions.md` — read this before
+  writing evidence files; for each property, note which assertions already exist
+  vs. which are missing
+- The list of user-named external references with their `why` notes (from the scope question), so the agent can consult them as part of its analysis — treat each as a lead to validate, not a fact (see `references/validating-claims.md`)
+- These instructions:
+
+> Examine the codebase through the lens of your assigned attention focus. Produce
+> properties in the standard catalog format. For each property, include your
+> confidence (high/medium/low) and what evidence in the codebase supports it. If
+> this focus yields no relevant properties for this system, say so and explain why.
+> Before you build a property on a claim from an external source — an issue or a
+> doc — validate it per `references/validating-claims.md`: a reported bug is a
+> lead, not a fact, and must be confirmed a real system defect (not the reporter's
+> environment or config) before it becomes a property.
+
+**Wildcard agent:** The wildcard agent (Focus 11) runs in parallel with the others
+but receives different context. Instead of a "look for" list, it receives:
+
+- The contents of `antithesis/scratchbook/sut-analysis.md`
+- The property catalog format from `references/property-catalog.md`
+- The wildcard directives from its focus description above
+- A one-line summary of each other focus, derived from the focus names and opening
+  sentences above (e.g., "Focus 1: Data Integrity — consistency guarantees,
+  invariants on stored state, corruption paths"). Do not include the detailed
+  "Look for:" lists — the wildcard should know what territory is covered, not how
+  the others search it.
+- The path to `antithesis/scratchbook/existing-assertions.md`
+- Access to the codebase
+- The list of user-named external references with their `why` notes (from the scope question), so the agent can consult them as part of its analysis — treat each as a lead to validate, not a fact (see `references/validating-claims.md`)
+
+### Agent Output
+
+Each agent writes detailed evidence to disk and returns a compact summary.
+
+**Written to disk:** For each property, write an evidence file to
+`properties/{slug}.md` in the scratchbook, where `{slug}` is a descriptive
+kebab-case name for the property. If a file already exists at the chosen name
+(another agent got there first), pick a different slug. Capture the specific
+code paths examined (files, functions, line numbers), the failure scenario, key
+observations, and any open questions with context on why they matter and what the
+answer would change. Write everything a future reader would need to understand
+why this property was identified and how the code is involved. For each
+property's SUT-side instrumentation suggestions, cross-reference
+`existing-assertions.md` and explicitly note whether each suggested assertion
+already exists in the codebase, is partially present, or is missing.
+
+**Returned to synthesis:**
+
+- Properties in catalog format (may be empty). Populate the Open Questions list
+  directly from the questions written into the evidence file, following
+  `references/property-catalog.md` ("Open Questions Conventions"). At this
+  stage, questions are typically untagged — they haven't been investigated
+  yet. The synthesis "Investigate Open Questions" step adds tags.
+- If a question you raised would contradict a prose claim you'd otherwise
+  write in **Property**, **Invariant**, **Antithesis Angle**, or **Why It
+  Matters**, write the prose at the level of generality the analysis
+  supports — don't overcommit to an answer the Open Questions list says is
+  uncertain. The list does the work of surfacing uncertainty; the prose
+  doesn't need parenthetical hedges. See `references/property-catalog.md`
+  ("Honest Summaries").
+- For each property, the evidence file slug and a brief rationale summary —
+  enough for synthesis to deduplicate, resolve conflicts, and evaluate assertion
+  type, but not the full evidence narrative
+- A brief note on what areas of the codebase it examined beyond specific properties
+- Assumptions made that affect multiple properties
+
+### Synthesis
+
+After all agents complete, synthesize into a single property catalog:
+
+- **Deduplicate:** Multiple agents finding the same property is a confidence
+  signal. Merge duplicates, noting which focuses identified them.
+- **Preserve unique finds:** Properties found by only one focus are the primary
+  value of the ensemble — don't drop them without cause.
+- **Resolve conflicts:** When agents disagree on assertion type or priority for
+  the same property, evaluate which reasoning is stronger. Note the disagreement
+  and resolution in the property's rationale.
+- **Assign slugs and organize:** Each agent already assigned a slug and wrote
+  an evidence file. For unique properties, accept the agent's slug. For merged
+  properties, pick the best slug from the contributing agents. If the canonical
+  slug differs from an agent's file, rename the evidence file to match. Group
+  properties into categories per the catalog format. The slug is the canonical
+  ID — see `references/property-catalog.md` for details.
+- **Record provenance:** For each property, note which focus(es) surfaced it.
+- **Merge duplicate evidence:** For properties found by multiple agents, read
+  the relevant evidence files and combine them into a single coherent file under
+  the canonical slug. Delete the extra evidence files. Unique properties already
+  have their final evidence file on disk — don't read them into context.
+- **Write property relationships:** Review the complete property set and write
+  `property-relationships.md` in the scratchbook. Group properties that share
+  evidence, code paths, or failure mechanisms into clusters. Note any suspected
+  dominance (where one property likely implies another). This is lightweight —
+  flag connections you noticed during synthesis, don't do deep analysis.
+- **Apply provenance frontmatter:** Write `sut_path`, current `commit`, today's `updated`, and the list of `external_references` (from the user's scope answer) to `property-catalog.md` and `property-relationships.md` per `references/scratchbook-setup.md`.
+- **Investigate open questions:** see "Investigate Open Questions" below.
+
+#### Investigate Open Questions
+
+For each property that has open questions in its evidence file, spawn an agent to investigate. Run these in parallel — one agent per property with open questions.
+
+Each agent receives the path to the evidence file, codebase access, the path to `antithesis/scratchbook/existing-assertions.md`, and `references/validating-claims.md`.
+
+Each agent must:
+
+- Read the evidence file's explanation of why each question matters and investigate the code to answer it. If a question asks whether a reported defect is real, confirm it per `references/validating-claims.md` — cite discriminating primary evidence, not the report's description.
+- Not fabricate answers. If a question can't be resolved from code, docs, or other available evidence, leave it open with partial findings noted. Tag `(partial: ...)` when there are partial findings; tag `(needs human input)` only after exhausting investigation against code and docs. Some questions legitimately need human input — that is a normal outcome, not a failure of the step.
+- Record an investigation log in the evidence file under an `### Investigation Log` heading (see `references/property-catalog.md` "Investigation Log") so the "attempted" check in `SKILL.md` self-review is auditable.
+- Update the evidence file: remove resolved questions, note partial findings, apply human-input tags. Correct any instrumentation suggestions against `existing-assertions.md` to reflect what already exists vs. what is missing.
+
+Each agent returns a short structured summary (not the full evidence file) describing: which questions were resolved, which remain (and in what state), whether the property changed (different invariant, different assertion type), and whether the property was invalidated with the reason.
+
+After all agents return, review the summaries. Update the catalog whenever a property is invalidated, changed, or has remaining unresolved questions — sync the Open Questions list under each affected property from the evidence file, following `references/property-catalog.md` ("Open Questions Conventions"). If a resolution makes a parametric term in the prose more specific, refine the prose field accordingly. Re-evaluate property relationships if any property changed or was invalidated.
+
+## Single-Agent Mode
+
+If your environment does not support sub-agents, work through the attention
+focuses as a sequential checklist:
+
+1. Read the SUT analysis.
+2. Read `antithesis/scratchbook/existing-assertions.md` so you have the full
+   picture of what's already instrumented before writing any evidence files.
+3. For each attention focus 1–10 in order, make an explicit pass through the
+   codebase with that lens. Treat any claim from an external source (an issue or
+   doc) as a lead to validate, not a fact — confirm a reported bug is a real
+   defect per `references/validating-claims.md` before building a property on it.
+   After each pass, add new properties to a running catalog. If a focus yields
+   nothing for this system, note why and move on.
+4. Run the wildcard pass (Focus 11) last. Unlike the other passes, the wildcard is
+   not a fresh examination — it deliberately builds on awareness of what the
+   previous passes covered, looking for properties they missed.
+5. After all 11 passes, review the full catalog for duplicates, gaps, and
+   consistency.
+6. Assign a descriptive kebab-case slug to each property and organize into final
+   form per the catalog format.
+7. Apply provenance frontmatter to `property-catalog.md` per `references/scratchbook-setup.md`.
+8. For each property, write an evidence file to `properties/{slug}.md` in the
+   scratchbook. Capture the supporting evidence, relevant code paths, failure
+   scenario, and key observations you encountered during your passes. For each
+   property's SUT-side instrumentation suggestions, cross-reference
+   `existing-assertions.md` and explicitly note whether each suggested assertion
+   already exists in the codebase, is partially present, or is missing.
+9. Review the complete property set and write `property-relationships.md` in the
+   scratchbook. Group properties that share evidence, code paths, or failure
+   mechanisms into clusters. Note any suspected dominance relationships. This is
+   lightweight — flag connections you noticed during the passes, don't do deep
+   analysis. Apply provenance frontmatter per `references/scratchbook-setup.md`.
+10. For each property with open questions in its evidence file, investigate the
+    code to answer them:
+
+    - Read the evidence file's explanation of why each question matters and
+      trace the code to answer it. If a question asks whether a reported defect
+      is real, confirm it per `references/validating-claims.md` — cite
+      discriminating primary evidence, not the report's description.
+    - Don't fabricate answers. Tag `(partial: ...)` when partial findings exist;
+      tag `(needs human input)` only after exhausting investigation against code
+      and docs.
+    - Record an investigation log in the evidence file under an `### Investigation Log` heading (see `references/property-catalog.md` "Investigation Log") so
+      the "attempted" check in `SKILL.md` self-review is auditable.
+    - Update the evidence file with resolved answers and their implications,
+      including correcting any instrumentation suggestions against
+      `existing-assertions.md`.
+    - Sync the Open Questions list under each property from the evidence file,
+      following `references/property-catalog.md` ("Open Questions Conventions").
+      If a resolution makes a parametric term in the prose more specific,
+      refine the prose field accordingly.
+    - If an answer changes the property or invalidates it, update accordingly.
+      Mark invalidated properties in the catalog with the reason.
+
+Treat each focus pass (Focuses 1–10) as a fresh examination. Resist the pull to
+skip a focus because earlier passes "already covered" that area — the point is
+to look at the same code from different angles. The wildcard pass is the
+exception — it runs last and uses knowledge of the covered territory to find
+gaps.
+
+## Output
+
+- `antithesis/scratchbook/property-catalog.md` — using the format defined in
+  `references/property-catalog.md`, including provenance frontmatter per
+  `references/scratchbook-setup.md`
+- `antithesis/scratchbook/properties/{slug}.md` — one per cataloged property
+- `antithesis/scratchbook/property-relationships.md` — suspected clusters and
+  connections, including provenance frontmatter per `references/scratchbook-setup.md`
diff --git a/.claude/skills/antithesis-research/references/property-evaluation.md b/.claude/skills/antithesis-research/references/property-evaluation.md
new file mode 100644
index 0000000..11408c8
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/property-evaluation.md
@@ -0,0 +1,315 @@
+# Property Evaluation
+
+## Goal
+
+Stress-test the property catalog by evaluating it as a portfolio — not
+individual properties in isolation, but the set as a whole. Discovery agents are
+biased toward finding properties; evaluation agents are biased toward finding
+problems with the catalog. Running both modes in the same stage degrades
+evaluation into post-hoc rationalization of what was already found. A fresh agent
+reading the catalog cold is more likely to see systematic gaps than the agent
+that just built it.
+
+This process runs after property discovery synthesis is complete and the
+deployment topology exists. It produces categorized findings that either improve
+the catalog directly (refinements), expand it (gap-filling), or escalate to the
+human for judgment (biases).
+
+## Prerequisites
+
+- `antithesis/scratchbook/property-catalog.md` exists (from property discovery)
+- `antithesis/scratchbook/sut-analysis.md` exists
+- `antithesis/scratchbook/deployment-topology.md` exists
+- `antithesis/scratchbook/existing-assertions.md` exists
+- `antithesis/scratchbook/properties/` evidence files exist for cataloged properties
+
+## Evaluation Lenses
+
+Each lens examines the property catalog from a different angle. Findings should
+be concrete and specific — not "coverage could be better" but "the SUT analysis
+flags replication lag as high-risk (section X) but no property in the catalog
+tests read-after-write consistency under replication delay."
+
+### 1. Antithesis Fit
+
+Evaluate whether each property is in Antithesis's sweet spot. Antithesis excels
+at exploring state spaces that deterministic tests cannot reach:
+
+- Timing-sensitive scenarios (race conditions, ordering dependencies)
+- Concurrency-sensitive scenarios (parallel access, lock contention)
+- Partial failure scenarios (network partitions, node crashes, restarts)
+- Combinatorial state exploration (interleavings, fault combinations)
+
+For each property, ask: does testing this property require exploring a state
+space that deterministic tests can't cover? If the invariant can be fully
+verified by a unit test with fixed inputs, it's not an Antithesis property — it's
+consuming search budget that could go toward properties where fault injection
+matters.
+
+Also check the inverse: are there properties where the catalog underestimates
+Antithesis's value? A property marked low-priority because "the happy path works"
+might be high-value precisely because Antithesis can explore the unhappy paths
+that nobody tests.
+
+Flag:
+
+- Properties that are really unit test or integration test territory
+- Properties where the assertion type doesn't match the testing mode (e.g., a
+  `Sometimes` that would need astronomically unlikely timing to trigger)
+- Properties where Antithesis's value is underestimated
+
+### 2. Coverage Balance
+
+Evaluate the property set as a portfolio against the SUT analysis and deployment
+topology. The question is not "is each property good?" but "is this the right
+set?"
+
+Read the SUT analysis section by section. For each area identified as high-risk
+or failure-prone, check whether the catalog has corresponding properties. Look
+for:
+
+- Risk areas with no properties (gaps)
+- Risk areas with disproportionately few properties relative to their risk level
+- Low-risk areas with disproportionately many properties (over-investment)
+- Missing property types — are safety, liveness, and reachability all represented
+  where appropriate?
+- Component blind spots — are properties distributed across the deployment
+  topology or concentrated in one service?
+
+Pay particular attention to gap patterns that property discovery commonly misses:
+
+- Features that interact with multiple subsystems (e.g., caching + replication,
+  authentication + session management, storage + garbage collection) — these
+  cross-cutting concerns often fall between attention focuses
+- Areas identified in bug history that didn't map cleanly to a single property
+  discovery focus
+- Operational scenarios (migration, upgrade, format conversion, failover) that
+  span lifecycle transitions rather than steady-state behavior
+- Optional or configurable features that change system behavior in ways the
+  default-path analysis didn't explore
+
+Also check for balance across assertion types. A catalog with 15 `Always`
+assertions and no `Sometimes` assertions is probably missing liveness properties.
+A catalog with no `Reachable` assertions may not be guiding Antithesis toward
+interesting code paths.
+
+### 3. Implementability
+
+Evaluate whether each property can actually be checked given the deployment
+topology, workload design constraints, and codebase structure.
+
+For each property, ask:
+
+- Can the invariant be observed from the workload, or does it require internal
+  state that isn't exposed? If the latter, is the suggested SUT-side
+  instrumentation feasible given the codebase?
+- Does the deployment topology support the failure scenarios the property needs?
+  A property about network partition behavior requires that the relevant services
+  be in separate containers.
+- Can the workload construct the operation sequences needed to exercise the
+  property? Some properties require specific preconditions that may be
+  impractical to set up reliably.
+- Are there resource or timing constraints that make the property impractical?
+  A property that requires sustaining high throughput for minutes may not work
+  within Antithesis timeline limits.
+
+Flag:
+
+- Properties that can't be observed without code changes the catalog doesn't
+  account for
+- Properties that need a deployment topology different from what's planned
+- Properties where the workload can't reliably create the necessary preconditions
+
+### 4. Wildcard
+
+The other 3 lenses have fixed perspectives. This lens has none. Its job is to
+find what they all miss.
+
+The orchestrator includes a one-line summary of each other lens so the wildcard
+knows what territory is covered. The wildcard's job starts where theirs ends.
+
+Directives (not principles — this lens is deliberately unconstrained):
+
+1. **Question the framing.** The discovery agents accepted the SUT analysis and
+   looked for properties within it. The other evaluation lenses accepted the
+   catalog and stress-tested it within their domains. This lens can question
+   whether the SUT analysis missed something that changes what properties matter.
+
+2. **Find missing angles.** Not missing data integrity properties (Coverage
+   Balance handles that) or missing observability (Implementability handles
+   that) — but missing *perspectives*. A failure scenario nobody modeled. A
+   usage pattern the workload can't represent. A property that only matters
+   under a combination of conditions no single lens would construct.
+
+3. **Cross-cut the lenses.** Look for issues that span multiple evaluation
+   domains. A property that Antithesis Fit says is high-value but
+   Implementability says is infeasible — is there a different formulation that
+   captures the same risk and is feasible?
+
+4. **Report what's odd.** If something about the catalog feels wrong but doesn't
+   fit a category, report it with your best attempt at why.
+
+## Ensemble Mode
+
+Spawn one agent per evaluation lens, in parallel.
+
+### Agent Instructions
+
+Before spawning agents, create a directory for evaluation evidence files at
+`antithesis/scratchbook/evaluation/`. Each agent receives:
+
+- The full contents of `antithesis/scratchbook/property-catalog.md`
+- The full contents of `antithesis/scratchbook/sut-analysis.md`
+- The full contents of `antithesis/scratchbook/deployment-topology.md`
+- The path to `antithesis/scratchbook/existing-assertions.md`
+- The path to `antithesis/scratchbook/properties/` (evidence files directory)
+- One evaluation lens (its full description from above)
+- The path to write its evidence file
+- The list of user-named external references with their `why` notes — treat each as a lead to validate, not a fact (see `references/validating-claims.md`)
+- The `sut_path`, current `commit`, and today's date (so the agent can write provenance frontmatter on its evidence file)
+- These instructions:
+
+> Evaluate the property catalog through the lens of your assigned evaluation
+> focus. Read the property evidence files when you need deeper context on a
+> specific property. Produce concrete, specific findings — not vague concerns.
+> Write your full analysis to the evidence file path provided, then return a
+> structured summary. Begin your evidence file with the standard provenance
+> frontmatter per `references/scratchbook-setup.md` before your analysis content.
+
+**Wildcard agent:** Receives the same inputs (including the user-named external
+references and the `sut_path`/`commit`/date for provenance frontmatter) plus a
+one-line summary of each other lens (e.g., "Lens 1: Antithesis Fit — evaluates
+whether properties are in Antithesis's sweet spot vs. unit/integration test
+territory"). Does not receive the detailed guidance for other lenses. Same
+provenance-frontmatter instruction as the standard agent applies.
+
+### Agent Output Format
+
+Each agent produces two artifacts:
+
+**Evidence file** (written to disk at the provided path) — full detailed
+analysis: every finding with complete evidence, property excerpts, detailed
+reasoning. This is the authoritative record.
+
+**Summary** (returned to orchestrator) — a structured summary for synthesis:
+
+**Findings** — ordered by scope (catalog-wide before property-specific). Each:
+
+- **Property/Properties**: Which property slugs are affected, or "catalog-wide"
+- **Concern**: What the problem is
+- **Scope**: Catalog-wide (affects the set composition) or property-specific
+- **Evidence**: Brief — full evidence is in the file
+- **Suggested action**: If applicable
+
+**Passes** — things checked that look correct. Brief.
+
+**Uncertainties** — things the lens couldn't determine, and why.
+
+### Synthesis and Categorization
+
+After all agents complete, synthesize by categorizing each finding:
+
+- **Gap**: A failure class, risk area, or property type is missing from the
+  catalog. The catalog can be expanded to cover it. The gap description should
+  be specific enough to serve as a targeted discovery focus.
+- **Bias**: A systematic orientation problem that the evaluation agents can
+  identify but not resolve — it requires human judgment about what matters most.
+  Examples: the catalog is oriented toward write-path safety but the product's
+  primary risk is read consistency; the property set assumes a deployment
+  topology that may not match production.
+- **Refinement**: A specific property needs adjustment — wrong assertion type,
+  wrong priority, infeasible observation method, missing SUT instrumentation
+  note. The fix is clear and can be applied directly.
+
+Evaluation agents assess scope and describe concerns — they do not categorize
+their own findings as Gap/Bias/Refinement. Categorization is the synthesis
+step's responsibility. A catalog-wide finding is a strong signal toward Gap or
+Bias, but the synthesizer may disagree if it sees the concern addressed by
+another agent's suggestion.
+
+Provide the paths to each agent's evidence file so the synthesizer can dig in
+when needed — when scope assessments conflict, when a finding's summary is
+ambiguous, or when verifying that the evidence supports the concern.
+
+Write the synthesis results to `antithesis/scratchbook/evaluation/synthesis.md`
+with provenance frontmatter per `references/scratchbook-setup.md`. The
+synthesizer is the writer for `synthesis.md` and has access to `sut_path`,
+`commit`, and `external_references` from the orchestrator. Include each finding,
+its category, and the action to be taken.
+
+### Addressing Findings
+
+After categorization:
+
+1. **Refinements**: Apply directly. Update the affected properties in the
+   catalog, their evidence files, and property relationships if needed. When a
+   refinement modifies a property, ensure the Open Questions list under the
+   property stays in sync with its evidence file per
+   `references/property-catalog.md` ("Open Questions Conventions").
+
+2. **Gaps**: Spawn targeted discovery agents to fill them. Each agent receives:
+   - The gap description from evaluation (used as a targeted attention focus)
+   - The SUT analysis
+   - The deployment topology (gaps from the Implementability lens may need
+     topology awareness to fill correctly)
+   - The existing property catalog (to avoid duplicating existing properties)
+   - The path to existing assertions
+   - The property catalog format from `references/property-catalog.md`
+   - The list of user-named external references with their `why` notes — treat each as a lead to validate, not a fact (see `references/validating-claims.md`)
+
+   Each agent returns properties in catalog format and writes evidence files. As
+   in property discovery, a reported bug must be confirmed a real system defect
+   per `references/validating-claims.md` before it becomes a property.
+   Targeted discovery agents may carry forward unresolved questions in their
+   new evidence files; they populate the Open Questions list per
+   `references/property-catalog.md` ("Open Questions Conventions"). Synthesize
+   gap-fill properties into the catalog using the same deduplication and merge
+   process from property discovery synthesis; this includes reconciling the
+   Open Questions list under each new property. Update property relationships
+   with any new clusters. When re-writing the catalog with the new gap-fill
+   properties, refresh `commit` and `updated` in the provenance frontmatter to
+   reflect the current codebase state. Preserve the existing `sut_path` and
+   `external_references`.
+
+3. **Biases**: Collect and present to the human with the evaluation evidence.
+   Include: the bias finding, the evidence from the evaluation agent(s), and
+   the specific judgment call needed. The human decides whether to accept the
+   catalog as-is, redirect discovery, or adjust priorities.
+
+After gap-filling, assess whether the additions are substantial enough to
+warrant a second evaluation pass. A single gap of 1-3 properties does not need
+re-evaluation. A gap that produces a new category of properties should be
+re-evaluated to verify it integrates well with the existing catalog.
+
+## Single-Agent Mode
+
+If your environment does not support sub-agents:
+
+1. Read all inputs: property catalog, SUT analysis, deployment topology, existing
+   assertions.
+2. For each evaluation lens 1-3 in order, make an explicit evaluation pass over
+   the catalog. After each pass, record findings. Write the detailed analysis
+   for each lens to the corresponding evaluation evidence file. Begin each lens
+   evidence file with provenance frontmatter per `references/scratchbook-setup.md`.
+3. Run the wildcard pass (Lens 4) last. Unlike the other passes, the wildcard
+   builds on awareness of what the first 3 found, looking for what they missed.
+4. Categorize all findings as Gap, Bias, or Refinement.
+5. Address findings: apply refinements, fill gaps by running targeted discovery
+   passes (validate any external claim per `references/validating-claims.md`
+   before building a property on it), collect biases for the human.
+6. Write the synthesis to `antithesis/scratchbook/evaluation/synthesis.md`. Begin
+   `synthesis.md` with provenance frontmatter per `references/scratchbook-setup.md`.
+
+Treat each lens pass (1-3) as a fresh examination. Resist the pull to skip a
+lens because an earlier pass "already covered" it.
+
+## Output
+
+- `antithesis/scratchbook/evaluation/antithesis-fit.md`
+- `antithesis/scratchbook/evaluation/coverage-balance.md`
+- `antithesis/scratchbook/evaluation/implementability.md`
+- `antithesis/scratchbook/evaluation/wildcard.md`
+- `antithesis/scratchbook/evaluation/synthesis.md`
+- Updated `antithesis/scratchbook/property-catalog.md` (after refinements and gap-filling)
+- Updated `antithesis/scratchbook/properties/` evidence files (as needed)
+- Updated `antithesis/scratchbook/property-relationships.md` (as needed)
diff --git a/.claude/skills/antithesis-research/references/scratchbook-artifacts.md b/.claude/skills/antithesis-research/references/scratchbook-artifacts.md
new file mode 100644
index 0000000..8b8b317
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/scratchbook-artifacts.md
@@ -0,0 +1,109 @@
+# Scratchbook Artifacts
+
+This document defines the artifact format contract for the Antithesis scratchbook. Skills that produce or consume scratchbook artifacts should reference this document to ensure consistency.
+
+## Artifact Set
+
+The scratchbook lives at `antithesis/scratchbook/` in the target repository and contains:
+
+| Artifact | Producer | Consumers | Purpose |
+|---|---|---|---|
+| `sut-analysis.md` | research | setup, workload | System architecture, components, data flows, failure-prone areas |
+| `existing-assertions.md` | research | discovery, evaluation | Scan of existing Antithesis SDK assertions in the codebase |
+| `deployment-topology.md` | research | setup | Minimal container topology for the Antithesis environment |
+| `property-catalog.md` | research | workload, refinement (future) | Concise, scannable catalog of testable properties with priorities |
+| `property-relationships.md` | research | refinement (future) | Suspected clusters and connections between properties |
+| `properties/{slug}.md` | research | refinement (future) | Per-property evidence trail and context |
+| `evaluation/synthesis.md` | research | refinement (future) | Categorized evaluation findings and actions taken |
+| `evaluation/{lens}.md` | research | refinement (future) | Per-lens evaluation evidence |
+
+## Provenance
+
+Every top-level artifact begins with YAML provenance frontmatter recording `sut_path`, `commit`, `updated`, and `external_references`. The format is defined in `references/scratchbook-setup.md`. Downstream skills (`antithesis-setup`, `antithesis-workload`) verify this provenance against the user's current target before consuming the artifacts. An artifact lacking frontmatter is treated as legacy.
+
+## Layout
+
+```
+antithesis/scratchbook/
+  sut-analysis.md
+  existing-assertions.md
+  deployment-topology.md
+  property-catalog.md
+  property-relationships.md
+  properties/
+    {slug}.md
+    {slug}.md
+    ...
+  evaluation/
+    synthesis.md
+    antithesis-fit.md
+    coverage-balance.md
+    implementability.md
+    wildcard.md
+```
+
+## Property IDs
+
+Each property has a unique identifier called a **slug**. Slugs are descriptive, kebab-case strings (e.g., `acked-writes-survive`, `no-split-brain`, `leader-election-completes`).
+
+The slug is the canonical identifier for a property. It is used consistently as:
+
+- The identifier in the property catalog heading (`### acked-writes-survive — Acknowledged Writes Survive`)
+- The filename for the property's evidence file (`properties/acked-writes-survive.md`)
+- The reference in the property relationships file
+
+This consistency means a slug uniquely identifies a property across all artifacts. There is no separate numeric ID.
+
+## Property Catalog
+
+The property catalog (`property-catalog.md`) is the human-readable summary of all discovered properties. It is designed for scanning and prioritization.
+
+The catalog carries the standard provenance frontmatter (see "Provenance" above and `references/scratchbook-setup.md`). The catalog also has structural requirements (slug format, property table layout, Open Questions conventions) documented in `references/property-catalog.md`.
+
+Each property's evidence file lives at `properties/{slug}.md`, where the slug matches the property's heading in the catalog.
+
+## Per-Property Evidence Files
+
+Each property in the catalog has a corresponding evidence file at `properties/{slug}.md`. These files capture the context and reasoning behind the property — information that would otherwise be lost when research compresses its findings into the catalog.
+
+Evidence files are freeform markdown. There is no required structure. Typical content includes:
+
+- **Evidence trail** — what led to identifying this property (code patterns, bug history, claimed guarantees, documentation)
+- **Relevant code paths** — specific files and functions involved
+- **Failure scenario** — what goes wrong if this property is violated
+- **Key observations** — anything expensive to rediscover (timing windows, configuration dependencies, subtle interactions)
+
+The purpose is to preserve what the research skill already knows so that downstream skills don't have to rediscover it.
+
+## Property Relationships
+
+The property relationships file (`property-relationships.md`) groups properties into suspected clusters based on shared evidence, code paths, or failure mechanisms. It also notes suspected dominance relationships (where one property likely implies another).
+
+This file is lightweight — research flags connections it noticed during discovery without doing deep analysis. Confirming or rejecting these relationships is the job of a future refinement skill.
+
+### Format
+
+```markdown
+# Property Relationships
+
+## [Cluster Name]
+Properties: slug-a, slug-b, slug-c
+Notes: Brief explanation of why these properties are related.
+Any suspected dominance or dependency relationships.
+
+## [Cluster Name]
+Properties: slug-d, slug-e
+Notes: ...
+```
+
+### What to Capture
+
+- Properties that share the same underlying code path or failure mechanism
+- Properties where one likely implies another (suspected dominance)
+- Properties that represent different facets of the same system behavior (e.g., safety and liveness sides of the same protocol)
+- Properties with causal dependencies (A is a precondition for B)
+
+### What Not to Capture
+
+- Properties that are merely in the same category (e.g., both are "data integrity" properties) but don't share evidence or code paths
+- Deep analysis of whether dominance actually holds — that requires deeper investigation than research provides
diff --git a/.claude/skills/antithesis-research/references/scratchbook-setup.md b/.claude/skills/antithesis-research/references/scratchbook-setup.md
new file mode 100644
index 0000000..3a710c0
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/scratchbook-setup.md
@@ -0,0 +1,76 @@
+# Scratchbook Setup
+
+## Initializing the Workspace
+
+Create the `antithesis/` directory at the repo root (or a user-specified location) to hold all Antithesis-related configuration, code, and research artifacts.
+
+Create the `antithesis/scratchbook/` subdirectory for the Antithesis scratchbook.
+
+If the directory or scratchbook files already exist, preserve them and extend them instead of overwriting them.
+
+## Provenance Frontmatter
+
+Every top-level artifact in the scratchbook must begin with YAML frontmatter recording what the artifact describes — which SUT, at what version, on what date, and which external references were consulted. This lets future readers and downstream skills (`antithesis-setup`, `antithesis-workload`) confirm the artifact still applies to the user's current target.
+
+```yaml
+---
+sut_path: 
+commit: 
+updated: YYYY-MM-DD
+external_references:
+  - path: 
+    why: 
+---
+```
+
+If the user said "just this directory" when asked the scope question, write `external_references: []`. The empty list means the question was asked and explicitly answered with no external references.
+
+Downstream skills (`antithesis-setup`, `antithesis-workload`) read whatever provenance fields are present in an artifact and describe what they find. They do not enforce a rigid schema match — older artifacts with fewer fields, or no frontmatter at all, are not "broken." An artifact lacking provenance frontmatter entirely predates this convention; downstream skills handle that case by simply describing what they found.
+
+Top-level artifacts requiring this frontmatter:
+
+- `sut-analysis.md`
+- `existing-assertions.md`
+- `property-catalog.md`
+- `deployment-topology.md`
+- `property-relationships.md`
+- `evaluation/synthesis.md`
+- `evaluation/{lens}.md`
+
+The rule applies uniformly even to artifacts whose `external_references` mirror another source. Two cases worth naming:
+
+- `property-relationships.md` is derivative of `property-catalog.md` (same write moment, same context). Copy `external_references` from the catalog when writing it.
+- `existing-assertions.md` is a fresh codebase scan written by the orchestrator inline (research SKILL.md workflow item 4), before discovery runs. There is no upstream artifact to copy from — the orchestrator writes the user's scope answer directly.
+
+In both cases, the resulting `external_references` matches the user's answer for this run. Uniformity simplifies downstream verification — one rule, applied everywhere — at negligible cost.
+
+Per-property evidence files (`properties/{slug}.md`) inherit context from `property-catalog.md` and do not need their own frontmatter.
+
+When updating an existing artifact, refresh `commit` and `updated` to reflect the current codebase state. Add new entries to `external_references` if additional sources were consulted; do not remove existing entries (the artifact already reflects them).
+
+## What the Antithesis Scratchbook Is For
+
+The Antithesis scratchbook is the central location for durable Antithesis handoff artifacts. Use it to:
+
+- Persist codebase-specific Antithesis analysis across conversation turns
+- Share canonical inputs and outputs across `antithesis-research`, `antithesis-setup`, and `antithesis-workload`
+- Record assumptions and open questions in the same files as the decisions they affect
+
+## Writing Research Outputs
+
+All research outputs should be written as markdown files in the Antithesis scratchbook. Use the following naming conventions:
+
+- `antithesis/scratchbook/sut-analysis.md` — System architecture, components, data flows, and attack surfaces
+- `antithesis/scratchbook/existing-assertions.md` — Scan of existing Antithesis SDK assertions in the codebase
+- `antithesis/scratchbook/property-catalog.md` — Catalog of testable properties with priorities and scoring
+- `antithesis/scratchbook/deployment-topology.md` — Container topology plan for the Antithesis environment
+- `antithesis/scratchbook/property-relationships.md` — Suspected clusters and connections between properties
+- `antithesis/scratchbook/properties/{slug}.md` — Per-property evidence files (one per cataloged property)
+- `antithesis/scratchbook/evaluation/synthesis.md` — Categorized evaluation findings and actions taken
+- `antithesis/scratchbook/evaluation/{lens}.md` — Per-lens evaluation evidence (antithesis-fit, coverage-balance, implementability, wildcard)
+
+When starting from scratch, initialize each file with provenance frontmatter (see "Provenance Frontmatter" above), then a short summary section plus explicit `Assumptions` and `Open Questions` headings. This makes later iterations and handoffs easier.
+
+Create the `antithesis/scratchbook/properties/` directory when writing the first property evidence file.
+
+Create the `antithesis/scratchbook/evaluation/` directory when starting property evaluation.
diff --git a/.claude/skills/antithesis-research/references/sut-analysis.md b/.claude/skills/antithesis-research/references/sut-analysis.md
new file mode 100644
index 0000000..dda1da6
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/sut-analysis.md
@@ -0,0 +1,73 @@
+# SUT Analysis
+
+## Goal
+
+Build a comprehensive understanding of the system — its architecture, components, data flows, concurrency model, and fault tolerance claims. This understanding drives every downstream decision: which properties to test, how to deploy, and where bugs are most likely hiding.
+
+## Codebase Exploration Strategy
+
+- **Read entrypoints:** Find `main()` functions, HTTP/gRPC server setup, CLI argument parsing. These reveal the system's public surface.
+- **Trace request paths:** Follow a request from ingress through middleware, business logic, and persistence. Note where state changes happen.
+- **Identify service boundaries:** Which processes talk to each other? What protocols do they use? Where are the network calls?
+- **Understand the data model:** What is stored, where, and how? What are the consistency guarantees? Is there replication?
+
+## Research External Sources
+
+Read the user-named external references gathered during scoping (see `SKILL.md` "Prerequisites and Scoping"). They are typically:
+
+- **Architecture docs:** Reveal the intended design and the guarantees the system claims to make.
+- **Open bugs (especially in target components):** Reported weaknesses. A bug report is one person's guess about a suspected defect and is often wrong about the cause — validate that each is a real system defect before building on it (see `references/validating-claims.md`). Once confirmed, Antithesis may find deeper variants.
+- **Recently closed bugs (regression targets):** A fix that handles one case may miss related edge cases. These are high-value Antithesis targets. Confirm the real mechanism from the fix and discussion first — some issues close as "not a bug."
+- **RFCs and design docs:** Reveal what developers know is hard. Sections labeled "future work" or "known limitations" are gold.
+- **Production incident reports:** Show what actually breaks in practice, not just what might break in theory.
+
+Everything here is a lead, not a fact: turn a source into an analysis statement or a property only after grounding it in primary evidence, and validate only what you build on. See `references/validating-claims.md`.
+
+## Identify Claimed Properties
+
+Every system makes guarantees. Extract them explicitly. Look for statements like:
+
+- "Exactly one leader per partition at all times"
+- "Acknowledged writes survive failover"
+- "Reads are linearizable" / "Reads are eventually consistent within X seconds"
+- "Automatic failover completes within Y seconds"
+- "No data loss under semi-synchronous replication"
+
+These claimed properties become properties for Antithesis to verify. A claimed guarantee is a claim to test, not a verified fact — don't state in the analysis that the guarantee holds; state that the system claims it and the property checks it. See `references/validating-claims.md`.
+
+## Identify Attack Surfaces
+
+Where do bugs hide? Focus your attention on these common patterns:
+
+- **State transitions under concurrent faults:** What happens during failover with in-flight writes? What if two nodes both think they're the leader?
+- **Polling/caching with stale data:** Topology watchers, health checks, DNS caches — anything that observes state asynchronously can act on outdated information.
+- **Race conditions between control plane and data plane:** The control plane says "node B is the new leader" but data plane traffic is still going to node A.
+- **Recovery from partial failures:** Some nodes down, not all. The system is in a degraded state — does it behave correctly?
+- **Component interactions making things worse:** Monitoring overloading a sick node, recovery actions conflicting with each other, retry storms.
+- **Runtime configuration changes under load:** What happens when you change a config value while the system is actively serving traffic?
+- **Health reporting accuracy:** The system says "healthy" but can't actually serve requests. Clients trust the health check and send traffic anyway.
+
+## Antithesis's Superpower Is Timing
+
+Focus on bugs that depend on "what if X happens at exactly the wrong moment during Y." Antithesis automatically explores execution interleavings. The most valuable properties are those that only fail under specific timing conditions:
+
+- A write arriving during a leader election
+- A config reload happening mid-request
+- A health check passing right before a process crashes
+- Two clients reading and writing the same key in overlapping transactions
+
+## Think About Partial Failures
+
+Not just "node is up or down" but the messy states in between:
+
+- Process is down but sidecar is up
+- Network partitioned to some peers but not others
+- Disk is slow but not dead
+- CPU starved but not OOM-killed
+- Connection pool exhausted but process is "healthy"
+
+These partial failure modes often reveal the most interesting bugs because systems are typically designed for clean failure, not degraded operation.
+
+## Output
+
+Write the analysis to `antithesis/scratchbook/sut-analysis.md`. Begin with provenance frontmatter (see `references/scratchbook-setup.md`).
diff --git a/.claude/skills/antithesis-research/references/sut-discovery.md b/.claude/skills/antithesis-research/references/sut-discovery.md
new file mode 100644
index 0000000..4003fdc
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/sut-discovery.md
@@ -0,0 +1,229 @@
+# SUT Discovery
+
+## Goal
+
+Systematically build understanding of the system under test by examining it from
+multiple independent perspectives. Each perspective (attention focus) looks at the
+same codebase, docs, and issue history but prioritizes different aspects, producing
+a richer analysis than a single unstructured pass.
+
+This process produces the SUT analysis artifact. Each agent (or each pass in
+single-agent mode) also uses the general methodology in `references/sut-analysis.md`
+but filters its work through its assigned focus.
+
+## Prerequisites
+
+- The repo or codebase location is known
+- The user has answered the scope question (see `SKILL.md` "Prerequisites and Scoping")
+- Any user-named external references are available
+
+## Attention Focuses
+
+Each focus defines a lens for examining the system. Findings should be concrete
+and specific — not "the system uses a database" but "PostgreSQL with synchronous
+replication to one standby, async to a second."
+
+### 1. Architecture and Data Flow
+
+Map components, service boundaries, communication protocols, request paths from
+ingress to persistence. Look for: entrypoints, middleware chains, internal RPCs,
+message passing patterns, data serialization boundaries.
+
+### 2. State Management and Persistence
+
+Databases, caches, queues, in-memory state, replication, consistency guarantees.
+Look for: what is stored where, how state moves between components, what happens
+to in-flight state during failures, persistence boundaries, cache invalidation
+strategies.
+
+### 3. Concurrency Model
+
+Threading, async patterns, locking, shared mutable state, synchronization
+primitives. Look for: thread pools, event loops, lock ordering, lock-free data
+structures, channels, shared mutable globals, concurrent access to collections.
+
+### 4. Safety Guarantees
+
+Explicitly claimed invariants the system promises will never be violated. Look for:
+statements in docs, comments, or design docs like "acknowledged writes survive
+failover," "exactly one leader per partition," "no duplicate processing," "data is
+never corrupted." These become safety properties for Antithesis to test — a
+claimed guarantee is a claim to test, not a verified fact (see
+`references/validating-claims.md`).
+
+### 5. Liveness Guarantees
+
+Progress properties the system promises will eventually hold. Look for: statements
+like "failover completes within X seconds," "queued work is eventually processed,"
+"clients eventually get a consistent view," "the system recovers automatically."
+These become liveness properties for Antithesis to test — a claimed guarantee is a
+claim to test, not a verified fact (see `references/validating-claims.md`).
+
+### 6. Bug History and Density
+
+Map where bugs have clustered and where they haven't. Look for: components with
+many filed issues (hotspots), components with no bug history (suspiciously quiet —
+possibly undertested rather than correct), recently closed bugs (regression targets
+where the fix may not cover all edge cases), recurring bug patterns that suggest
+systemic issues. A filed issue is a reported bug, not a confirmed one — the
+reporter is often wrong about the cause. Before you build a property on a reported
+bug, confirm the defect is real from primary evidence, not the issue's description
+(see `references/validating-claims.md`).
+
+### 7. Existing Test Strategy
+
+What kinds of tests exist and what do they actually exercise. Look for: unit tests,
+integration tests, end-to-end tests, chaos/fault injection tests, load tests. For
+each category: what scenarios do they cover, what do they mock or stub out, what
+failure modes do they explicitly not test. The goal is understanding where Antithesis
+adds value rather than duplicating existing coverage.
+
+### 8. Failure and Degradation Modes
+
+Error handling patterns, retry logic, timeout handling, partial failure paths,
+degraded operation. Look for: catch-all error handlers, retry loops without
+backoff, hardcoded timeouts, circuit breakers, fallback paths, graceful degradation
+logic, health check implementations. Focus on the messy states between "fully up"
+and "fully down."
+
+### 9. External Dependencies and Integration Points
+
+Third-party services, databases, message brokers, DNS, sidecars, cloud provider
+APIs. Look for: points where the system's behavior depends on something it doesn't
+control, how failures in dependencies are handled (or not), timeout and retry
+policies for external calls, fallback behavior when dependencies are unavailable.
+
+### 10. Product Context
+
+What the product does from a user's perspective, what workflows matter most, what
+a user-visible failure looks like. Look for: user-facing documentation, API docs,
+CLI help text, UI flows, onboarding guides. The goal is grounding the technical
+analysis in real-world impact — a bug in a rarely-used admin endpoint matters less
+than one in the critical write path.
+
+### 11. Unproven Assumptions
+
+Implicit axioms the system is built on that are never validated. Look for: error
+paths that don't exist, dependencies with no failure handling, comments like "this
+shouldn't happen," catch blocks that log and swallow, hardcoded timeouts with no
+rationale, code that assumes a service is always reachable, assumptions about clock
+synchronization, assumptions about message ordering. These are often the most
+productive Antithesis targets because they represent conditions the developers never
+tested.
+
+### 12. Wildcard
+
+The other 11 focuses have fixed lenses — they see what their descriptions tell
+them to look for. This focus has no fixed lens. Its job is to find what they will
+all miss: the weird, the novel, the thing that doesn't fit any category but matters
+anyway. These are not principles — this focus is deliberately unconstrained.
+
+1. **Know the covered territory.** The orchestrator provides a one-line summary of
+   each other focus. Understand their territory. Your job starts where theirs ends.
+
+2. **Look for the non-obvious.** System characteristics that are surprising,
+   unusual, or don't fit standard categories. A component that's architecturally
+   bizarre but works. A dependency relationship that seems backwards. A design
+   choice that's technically sound but fragile in ways nobody discusses.
+
+3. **Find missing concepts.** Not missing state management (Focus 2 handles that)
+   or missing failure modes (Focus 8 handles that) — but missing *ideas*. A failure
+   mode that doesn't fit any standard category. A system behavior that only emerges
+   from the interaction of multiple components. A deployment assumption nobody
+   documents.
+
+4. **Cross-cut.** Issues that span multiple focuses but belong to none. Where
+   concurrency assumptions interact with persistence guarantees. Where external
+   dependency behavior affects the failure model in ways neither focus would surface
+   alone. Where the test strategy creates false confidence about a guarantee.
+
+5. **Question the frame.** The other focuses accept the system as presented and
+   analyze its characteristics. You can question the presentation. Is the documented
+   architecture how it actually works? Are there implicit invariants the system
+   depends on that nobody states? Is there a subsystem everyone treats as reliable
+   that probably isn't?
+
+6. **Report what's odd.** If something strikes you as unusual, unexpected, or
+   suspicious but you can't fully articulate why — report it anyway with your best
+   attempt at why it feels wrong. A vague signal from the wildcard is still signal.
+
+## Ensemble Mode
+
+If your environment supports spawning sub-agents, run SUT discovery as an
+ensemble — one agent per attention focus, in parallel.
+
+### Agent Instructions
+
+Spawn one agent per focus. Each agent receives:
+
+- The general SUT analysis methodology from `references/sut-analysis.md`
+- One attention focus (its full description and "look for" guidance from above)
+- Access to the codebase, documentation, and issue tracker
+- The list of user-named external references with their `why` notes (from the scope question), so the agent can consult them as part of its analysis — treat each as a lead to validate, not a fact (see `references/validating-claims.md`)
+- These instructions:
+
+> Examine the system through the lens of your assigned attention focus, using the
+> general methodology in `references/sut-analysis.md` as a guide. Produce concrete,
+> specific findings — not vague summaries. If your focus yields little for this
+> system, say so and explain why.
+
+**Wildcard agent:** The wildcard agent (Focus 12) runs in parallel with the others
+but receives different context. Instead of a "look for" list, it receives:
+
+- The general SUT analysis methodology from `references/sut-analysis.md`
+- The wildcard directives from its focus description above
+- A one-line summary of each other focus, derived from the focus names and opening
+  sentences above (e.g., "Focus 1: Architecture and Data Flow — maps components,
+  service boundaries, communication protocols, and request paths"). Do not include
+  the detailed "Look for:" lists — the wildcard should know what territory is
+  covered, not how the others search it.
+- Access to the codebase, documentation, and issue tracker
+- The list of user-named external references with their `why` notes (from the scope question), so the agent can consult them as part of its analysis — treat each as a lead to validate, not a fact (see `references/validating-claims.md`)
+
+### Agent Output Format
+
+Each agent returns:
+
+- Findings organized by the focus area (concrete observations, not vague summaries)
+- A brief note on what areas of the codebase, docs, and issues it examined
+- Assumptions made or open questions encountered
+
+### Synthesis
+
+After all agents complete, synthesize into a single SUT analysis:
+
+- **Merge structural findings:** Architecture, state, and concurrency findings
+  form the factual backbone. Resolve contradictions by checking the code.
+- **Consolidate guarantees:** Safety and liveness findings from multiple agents
+  may overlap. Deduplicate, preferring the most precise phrasing.
+- **Layer contextual findings:** Bug history, test strategy, product context, and
+  unproven assumptions add depth to the structural analysis. Attach them to the
+  relevant components rather than listing them separately.
+- **Preserve unique observations:** Findings from only one agent are valuable —
+  they represent what a single-pass analysis would have missed.
+- **Record provenance:** Note which focus(es) surfaced each major finding.
+
+## Single-Agent Mode
+
+If your environment does not support sub-agents, work through the attention
+focuses as a sequential checklist:
+
+1. Read `references/sut-analysis.md` for general methodology.
+2. For each attention focus 1–11 in order, make an explicit pass through the
+   codebase, docs, and issues with that lens. After each pass, add findings to a
+   running analysis document. If a focus yields little for this system, note why
+   and move on.
+3. Run the wildcard pass (Focus 12) last. Unlike the other passes, the wildcard is
+   not a fresh examination — it deliberately builds on awareness of what the
+   previous passes covered, looking for what they missed.
+4. After all 12 passes, review the full analysis for gaps and consistency.
+5. Organize into the final SUT analysis.
+
+Treat each pass (1–11) as a fresh examination. Resist the pull to skip a focus
+because earlier passes "already covered" that area — the point is to look at the
+same system from different angles. The wildcard pass is the exception — it runs
+last and uses knowledge of the covered territory to find gaps.
+
+## Output
+
+The output feeds into `antithesis/scratchbook/sut-analysis.md`, including provenance frontmatter per `references/scratchbook-setup.md`.
diff --git a/.claude/skills/antithesis-research/references/validating-claims.md b/.claude/skills/antithesis-research/references/validating-claims.md
new file mode 100644
index 0000000..392ce30
--- /dev/null
+++ b/.claude/skills/antithesis-research/references/validating-claims.md
@@ -0,0 +1,81 @@
+# Validating External Claims
+
+External sources tell you things about the system. They are leads, not facts.
+A claim earns nothing by being written down somewhere — it earns a place in
+your analysis only when you have grounded it in evidence that *exhibits* the
+behavior (the code, logs, a repro), not merely a source that *asserts* it.
+
+You validate what you build on, not everything you read. A tracker holds
+thousands of claims; you will turn a handful into properties. Validation is the
+price of that promotion. A claim you are not building on, you leave inert. The
+moment a claim needs validation is the moment you would state it as something
+the system does, or turn it into a property.
+
+## Two Kinds of External Input
+
+**Claimed guarantees** — the system's own docs, design notes, and code comments
+asserting intended behavior ("acknowledged writes survive failover"). Turn
+these into properties and let Antithesis test them; that is what properties are
+for. The one discipline: do not write that the guarantee *holds*. Write that
+the system claims it and the property verifies it. A claimed guarantee is a
+claim to test, not a verified fact — docs drift and lie.
+
+**Bug reports** — an issue where someone reports a suspected defect. The weakest
+input there is: one person's guess about their own problem, and the reporter is
+frequently wrong about the cause. A bug report is nothing until you validate
+that the reported defect is a real defect in the system. Read the primary
+evidence — the attached logs, the repro, the mechanism in the code, the issue's
+resolution — and rule out the ordinary alternatives: the reporter's
+configuration, their environment, their misunderstanding. Only when the
+evidence shows a real system defect does the report become a weakness worth a
+property, grounded in the mechanism you found, not the reporter's description.
+Production incident reports and postmortems are the same kind of lead —
+usually stronger evidence than a single-reporter issue, but still validate that
+the cause was correctly attributed to the system before you build on it.
+
+## What Validation Requires
+
+Name the competing explanation before you look. For a reported bug it is almost
+always "this is the reporter's environment or config, not the system." Then read
+the evidence that tells the two apart, and record the specific detail that
+settles it. "The issue says split-brain" is not validation — it cites the claim.
+"The logs show hostname resolution failing before any quorum event" is
+validation — it discriminates. If you cannot quote the discriminating detail,
+you have not validated the claim, and it does not enter.
+
+Read past the headline to the part that carries the evidence. A claim in a
+title or first paragraph is a headline; the logs, repro, comments, and
+resolution are where the truth is — and they are the parts a hurried read
+skips. Weigh the source's standing, too: an open, uncommented, single-reporter
+issue is far weaker than a maintainer-confirmed one.
+
+If you investigate and cannot settle it, the claim still does not become a fact.
+Record it as an open question for a human under the file-level `Open Questions`
+heading (see `references/scratchbook-setup.md`) — an unvalidated claim has no
+property to attach to. Use the state tags from "Open Questions Conventions" in
+`references/property-catalog.md`. It does not become a property premise or a
+stated behavior.
+
+## Worked Example: Valkey Issue #1322
+
+The issue is titled "Sentinel split-brain after failover." Read only that far
+and you conclude Valkey's Sentinel has a split-brain defect — and every property
+built on top inherits a false premise.
+
+The issue is open, has no comments, no labels, one reporter. That alone makes it
+weak testimony, not an established behavior.
+
+The reporter attached six logs and a config. They tell a different story. The
+after-restart logs are flooded with `# Failed to resolve hostname
+'node-mz-0.example.com'`. The config uses hostname-based Sentinel
+(`resolve-hostnames yes`) and announces `node-0.example.com` — a different name
+scheme than the `node-mz-*` names the running nodes try to resolve. The failover
+loops: `+try-failover` then `-failover-abort-no-good-slave`, because Sentinel
+cannot resolve the replica it would promote.
+
+The competing explanation is the correct one: the nodes cannot resolve each
+other's hostnames after restart. That is a deployment/DNS misconfiguration.
+Sentinel is behaving correctly given names it cannot resolve. The report does
+not describe a Valkey defect, so it does not become a property. The
+discriminating evidence is those resolution-failure log lines, before any quorum
+event.
diff --git a/.claude/skills/antithesis-skills-feedback/SKILL.md b/.claude/skills/antithesis-skills-feedback/SKILL.md
new file mode 100644
index 0000000..5f731c3
--- /dev/null
+++ b/.claude/skills/antithesis-skills-feedback/SKILL.md
@@ -0,0 +1,76 @@
+---
+name: antithesis-skills-feedback
+description: >
+  File a bug report or feedback on the Antithesis skills by opening a
+  pre-filled GitHub issue URL (skill name, skill version, agent, short
+  summary). Load when the user wants to report a problem with, or give
+  feedback on, any Antithesis skill. Does not auto-submit — presents the
+  URL for the user to review.
+metadata:
+  version: "2026-07-14 1f59c97"
+---
+
+# Antithesis Skills feedback
+
+**Skill version:** `2026-07-14 1f59c97`
+
+Help the user file a bug report against the Antithesis skills by opening a pre-filled GitHub issue.
+
+## Base URL
+
+```
+https://github.com/antithesishq/antithesis-skills/issues/new?template=bug_report.yml
+```
+
+## Pre-filling fields via URL parameters
+
+GitHub issue templates accept query parameters to pre-fill fields. Each
+parameter key corresponds to the `id` of a field in the template. Append them as
+`&=` with values **percent-encoded** (spaces → `%20`, newlines →
+`%0A`, etc.).
+
+The available fields are:
+
+| Parameter           | Type     | What to fill                                                                                          |
+| ------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
+| `what-happened`     | textarea | Brief summary of what went wrong. Keep to 1-2 sentences — the user will elaborate.                    |
+| `expected`          | textarea | Leave **blank** — only the user knows what they expected.                                             |
+| `skill`             | input    | The skill that was active (e.g. `antithesis-triage`, `antithesis-setup`).                             |
+| `skill-version`     | input    | Run `git -C  rev-parse --short HEAD` to get the commit SHA.                              |
+| `agent`             | input    | The agent name and version (e.g. `Claude Code 1.0.20`). Use the agent info available in your context. |
+| `agent-explanation` | textarea | Your honest self-assessment: what did you do, and why did it go wrong? 2-4 sentences max.             |
+| `context`           | textarea | Leave **blank** — the user can add screenshots and logs themselves.                                   |
+
+You can also set the issue `title` via a `title=` parameter.
+
+## How to construct the URL
+
+1. **Identify the skill** that was being used when the problem occurred.
+2. **Get the skill version** by running `git rev-parse --short HEAD` in the skills repo if you know where it is.
+3. **Determine agent info** from your environment context (agent name + version).
+4. **Write a brief `what-happened`** summary (1-2 sentences).
+5. **Write your `agent-explanation`** — a short, honest self-assessment of what went wrong (2-4 sentences).
+6. **Set the `title`** to a short description of the bug (under 80 characters).
+7. **Percent-encode** all parameter values.
+8. **Assemble the URL** by appending only the fields you can meaningfully fill. Do NOT fill `expected` or `context` — those are for the user.
+
+### URL length limit
+
+**Keep the total URL under 2000 characters.** Browsers and GitHub may truncate
+or reject longer URLs. If the URL would exceed this limit:
+
+- Shorten `what-happened` and `agent-explanation` first — the user can edit these in the form.
+- Drop `agent-explanation` entirely if still too long.
+- As a last resort, drop `what-happened` too and only pre-fill `skill`, `skill-version`, `agent`, and `title`.
+
+### Example URL
+
+```
+https://github.com/antithesishq/antithesis-skills/issues/new?template=bug_report.yml&title=Triage%20skill%20fails%20to%20parse%20property%20results&skill=antithesis-triage&skill-version=1c45cd3&agent=Claude%20Code%201.0.20&what-happened=The%20triage%20skill%20crashed%20while%20parsing%20property%20results%20from%20the%20Antithesis%20dashboard.&agent-explanation=I%20attempted%20to%20extract%20property%20data%20from%20the%20results%20page%20but%20the%20HTML%20structure%20did%20not%20match%20the%20expected%20selectors.
+```
+
+## Presenting to the user
+
+**Do NOT auto-submit the issue.** Open the URL (or print it if no browser is
+available) so the user can review the pre-filled fields, add their own details
+to `expected` and `context`, and submit when ready.
diff --git a/.claude/skills/antithesis-triage/SKILL.md b/.claude/skills/antithesis-triage/SKILL.md
new file mode 100644
index 0000000..13f96cd
--- /dev/null
+++ b/.claude/skills/antithesis-triage/SKILL.md
@@ -0,0 +1,145 @@
+---
+name: antithesis-triage
+description: >
+  Triage Antithesis test reports to understand what happened in a run: look
+  up runs, check status, investigate failed properties (assertions), view
+  metadata, download logs, inspect findings, and examine environmental
+  details. Load after a run completes or when investigating a failure.
+compatibility: Requires snouty (https://github.com/antithesishq/snouty), and jq.
+metadata:
+  version: "2026-07-14 1f59c97"
+---
+
+# Antithesis Run Triage
+
+**Skill version:** `2026-07-14 1f59c97`
+
+Use this skill to analyze Antithesis test runs.
+
+**Reference files:** This skill's `references/` directory contains detailed guides for specific tasks. Do NOT read them all up front — only read a reference file when you are told to. Each reference file is mentioned by name at the point where it is needed.
+
+## Prerequisites
+
+- DO NOT PROCEED if `snouty` is not installed. See `https://raw.githubusercontent.com/antithesishq/snouty/refs/heads/main/README.md` for installation options.
+- DO NOT PROCEED if `snouty` is not at least version 0.6.0. Use `snouty --version` to find the version. Use `snouty update` to update.
+- DO NOT PROCEED if `jq` is not installed. See `https://jqlang.org/download/` for installation options.
+
+### Preflight: confirm triage can work
+
+The triage skill talks to Antithesis through the snouty API (`snouty runs ...`). Before doing any work, confirm the setup is ready:
+
+```bash
+snouty doctor --json
+```
+
+This validates API connectivity and reports snouty's resolved configuration.
+
+Proceed only when the top-level `ok` is `true` and the `api_key` check's `status` is `ok`. Otherwise relay the failing check's `message`/`notes` and stop; if `api_key` is the failing check, tell the user to set `ANTITHESIS_API_KEY`.
+
+The `settings` array in the same output carries snouty's resolved parameters. For example, you can look up the resolved tenant with:
+
+```bash
+snouty doctor --json | jq -r '.settings[] | select(.name == "tenant") | .value'
+```
+
+## Gathering user input
+
+Before starting, collect the following from the user:
+
+1. **Tenant Name** (required) — You must know the tenant name. Read snouty's resolved tenant from `snouty doctor --json` (see Preflight); snouty resolves it from the environment or a settings file, so trust that value. Only ask the user if `doctor` shows the tenant is unset.
+
+2. **What they want to know** — Are they interested in all failures in a specific run? Are they investigating a specific failure? Are they getting a general overview? Comparing runs? This determines which workflow to follow.
+
+## How to get information from a run
+
+Your main method to obtain information is to use the `snouty runs