From 7df005847ffc45526de42bf6bf7a8ec6c959cbe0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 10:24:03 +0900 Subject: [PATCH 01/10] test(browser): require MV3 failure profile cleanup evidence --- ...st_mv3_failure_profile_cleanup_contract.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_mv3_failure_profile_cleanup_contract.py diff --git a/tests/test_mv3_failure_profile_cleanup_contract.py b/tests/test_mv3_failure_profile_cleanup_contract.py new file mode 100644 index 000000000..730c44371 --- /dev/null +++ b/tests/test_mv3_failure_profile_cleanup_contract.py @@ -0,0 +1,53 @@ +"""Contract for MV3 restart-trial profile cleanup evidence on browser failure.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class Mv3FailureProfileCleanupContractTests(unittest.TestCase): + """Require failed extension-compatibility trials to retain teardown evidence.""" + + def _namespace(self, name: str) -> dict[str, object]: + return runpy.run_path(str(RUNNER), run_name=name) + + def test_failed_restart_pass_returns_profile_cleanup_evidence(self) -> None: + """A failed initial or restarted browser pass must retain profile cleanup proof.""" + + namespace = self._namespace("mv3_failure_cleanup_behavior") + run_trial = namespace["_run_restart_trial"] + + def fail_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: + raise RuntimeError("synthetic MV3 browser failure") + + run_trial.__globals__["_run_browser_pass"] = fail_browser_pass + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 11, + ) + + self.assertEqual(result["trial_number"], 11) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertIs(result["profile_cleaned"], True) + self.assertNotIn("synthetic MV3 browser failure", repr(result)) + + def test_acceptance_gate_requires_cleanup_evidence_for_every_mv3_trial(self) -> None: + """Failed MV3 trials must not be filtered out of the profile-cleanup gate.""" + + runner = RUNNER.read_text(encoding="utf-8") + self.assertIn("mv3_profiles_cleaned = all(", runner) + self.assertIn('trial.get("profile_cleaned") is True', runner) + self.assertIn('"profiles_cleaned": mv3_profiles_cleaned', runner) + self.assertIn("Manifest V3 profile cleanup gate failed", runner) + + +if __name__ == "__main__": + unittest.main() From 2301459a20bd5561054b7dd7d2151fd327634026 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:20:04 +0900 Subject: [PATCH 02/10] feat(browser): retain MV3 failure cleanup evidence --- scripts/ci/run_mv3_compatibility.py | 62 +++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index c81ebf642..83da49174 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -723,26 +723,49 @@ def _run_restart_trial( fixture_url: str, trial_number: int, ) -> dict[str, Any]: - """Run one independent initial/restart pair and return credential-free evidence.""" + """Run one independent initial/restart pair and retain cleanup evidence on failure.""" trial_started = time.monotonic() + profile_path: pathlib.Path + initial: dict[str, Any] | None = None + restarted: dict[str, Any] | None = None + failure_type: str | None = None with tempfile.TemporaryDirectory( prefix=f"originweave-mv3-trial-{trial_number}-" ) as profile_dir: - initial = _run_browser_pass( - chrome_bin, - chromedriver_bin, - fixture_url, - profile_dir, - "initialized", - ) - restarted = _run_browser_pass( - chrome_bin, - chromedriver_bin, - fixture_url, - profile_dir, - "persisted", - ) + profile_path = pathlib.Path(profile_dir) + try: + initial = _run_browser_pass( + chrome_bin, + chromedriver_bin, + fixture_url, + profile_dir, + "initialized", + ) + restarted = _run_browser_pass( + chrome_bin, + chromedriver_bin, + fixture_url, + profile_dir, + "persisted", + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + failure_type = type(exc).__name__ + profile_cleaned = not profile_path.exists() + if not profile_cleaned: + raise RuntimeError(f"Manifest V3 profile cleanup failed in trial {trial_number}") + + duration_ms = round((time.monotonic() - trial_started) * 1000) + if failure_type is not None: + return { + "trial_number": trial_number, + "passed": False, + "failure_type": failure_type, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if initial is None or restarted is None: + raise RuntimeError("Manifest V3 restart trial returned incomplete browser evidence") initial_count = int(initial["worker_start_count"]) restarted_count = int(restarted["worker_start_count"]) @@ -777,7 +800,8 @@ def _run_restart_trial( "storage_persistence": restarted["storage_persistence"], }, ], - "duration_ms": round((time.monotonic() - trial_started) * 1000), + "profile_cleaned": True, + "duration_ms": duration_ms, } @@ -1412,6 +1436,9 @@ def main() -> int: 1 for trial in trial_results if trial.get("passed") is True ) trial_pass_rate = successful_trials / REPEATABILITY_TRIALS + mv3_profiles_cleaned = all( + trial.get("profile_cleaned") is True for trial in trial_results + ) successful_results = [ trial for trial in trial_results if trial.get("passed") is True ] @@ -1540,6 +1567,7 @@ def main() -> int: "repeatability_trials": REPEATABILITY_TRIALS, "successful_trials": successful_trials, "trial_pass_rate": trial_pass_rate, + "profiles_cleaned": mv3_profiles_cleaned, "surfaces": common_surfaces, "trial_results": trial_results, "browser_passes": ( @@ -1564,6 +1592,8 @@ def main() -> int: "duration_ms": round((time.monotonic() - started) * 1000), } print(json.dumps(evidence, sort_keys=True)) + if not mv3_profiles_cleaned: + raise RuntimeError("Manifest V3 profile cleanup gate failed") if successful_trials != REPEATABILITY_TRIALS: raise RuntimeError( "Manifest V3 repeatability gate failed: " From 42af52f77336112c4fbe1da5c5283899f6bd288e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:23:29 +0900 Subject: [PATCH 03/10] docs: record MV3 failure cleanup evidence --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d9f113c..5db26973d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added - Failed ordinary and forced-close Agent Task browser trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, and separate aggregate compatibility gates require cleanup proof from every trial rather than filtering unsuccessful trials out; this does not attest adversarial filesystem erasure, process termination, or arbitrary browser recovery. +- Failed Manifest V3 restart trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, successful trials record the same cleanup fact, and an aggregate compatibility gate requires teardown proof from every MV3 trial before repeatability acceptance without retaining exception messages; this does not attest adversarial filesystem erasure, browser-process termination, or cleanup outside the controlled temporary profile. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. From 60183b8158db287cb9f692643e92df50b2abb42d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:54:05 +0900 Subject: [PATCH 04/10] docs: inherit browser recovery release notes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db26973d..76589775b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Pinned Chrome-for-Testing Agent Task evidence now captures a bounded sampled Chromium root-plus-descendant process count and RSS total from one `/proc` status sweep, with bounded failure-type diagnostics while preserving the root-only metric and making no trusted per-task attribution claim. - Pinned Chrome-for-Testing Agent Task evidence now locates the controlled result by exact browser-computed `status`/`Task result` semantics and records only a bounded canonical SHA-256 digest plus stable field identity for the extracted synthetic value, without emitting the raw value. +- Pinned Chrome-for-Testing Agent Task semantic-observation evidence is now canonicalized as compact sorted-key UTF-8 JSON and capped at 4,096 bytes, accepting the exact limit while failing closed on empty, non-object, or oversized observations before they enter successful trial evidence. +- Pinned Chrome-for-Testing forced-close recovery evidence now closes a disposable controlled browsing context, requires structured exact `no such window` failure on the next current-context command, proves a separate survivor context remains usable in the same WebDriver session, repeats the probe with isolated profile cleanup, and fails closed on substring lookalikes or malformed error framing. - Controlled pinned-Chromium Agent Task acceptance now fails closed unless the temporary profile is pristine before launch, browser-observed cookies and Web Storage are empty, saved-credential services are disabled, extensions are disabled by launch policy, and the profile is removed afterward; bounded per-trial evidence does not claim OS- or browser-attested absence of every credential mechanism. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. From c5a91bcdea9f39e5e4a9b6e834ce54c877b7d47f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:01:55 -0700 Subject: [PATCH 05/10] chore(stack): sync current Agent Task cleanup prerequisite --- scripts/ci/run_mv3_compatibility.py | 16 +++++- ...est_agent_task_failure_cleanup_contract.py | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 83da49174..39f0ca80c 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1066,7 +1066,13 @@ def _run_agent_task_trial( fixture_url, profile_dir, ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: @@ -1332,7 +1338,13 @@ def _run_agent_task_forced_close_trial( fixture_url, profile_dir, ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: diff --git a/tests/test_agent_task_failure_cleanup_contract.py b/tests/test_agent_task_failure_cleanup_contract.py index 68a2326e0..7d0233d69 100644 --- a/tests/test_agent_task_failure_cleanup_contract.py +++ b/tests/test_agent_task_failure_cleanup_contract.py @@ -4,6 +4,7 @@ import pathlib import runpy +import subprocess import unittest ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -39,6 +40,32 @@ def fail_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: self.assertIs(result["profile_cleaned"], True) self.assertNotIn("synthetic controlled browser failure", repr(result)) + def test_teardown_timeout_returns_profile_cleanup_evidence(self) -> None: + """A reviewed process teardown timeout must become one failed trial, not abort the run.""" + + namespace = self._namespace("agent_task_teardown_timeout_cleanup_behavior") + run_trial = namespace["_run_agent_task_trial"] + + def timeout_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: + raise subprocess.TimeoutExpired( + cmd="private-controlled-chromedriver-path", + timeout=5, + ) + + run_trial.__globals__["_run_agent_task_browser_pass"] = timeout_browser_pass + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 8, + ) + + self.assertEqual(result["trial_number"], 8) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "TimeoutExpired") + self.assertIs(result["profile_cleaned"], True) + self.assertNotIn("private-controlled-chromedriver-path", repr(result)) + def test_failed_forced_close_pass_returns_profile_cleanup_evidence(self) -> None: """A forced-close probe failure must still prove that its task profile was removed.""" @@ -62,6 +89,32 @@ def fail_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: self.assertIs(result["profile_cleaned"], True) self.assertNotIn("synthetic forced-close browser failure", repr(result)) + def test_forced_close_teardown_timeout_returns_profile_cleanup_evidence(self) -> None: + """Forced-close teardown timeout must retain cleanup evidence without raw command text.""" + + namespace = self._namespace("agent_task_forced_close_teardown_timeout_behavior") + run_trial = namespace["_run_agent_task_forced_close_trial"] + + def timeout_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: + raise subprocess.TimeoutExpired( + cmd="private-forced-close-chromedriver-path", + timeout=5, + ) + + run_trial.__globals__["_run_agent_task_forced_close_browser_pass"] = timeout_browser_pass + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 10, + ) + + self.assertEqual(result["trial_number"], 10) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "TimeoutExpired") + self.assertIs(result["profile_cleaned"], True) + self.assertNotIn("private-forced-close-chromedriver-path", repr(result)) + def test_acceptance_gate_requires_cleanup_evidence_for_every_trial(self) -> None: """Failed trials must not be filtered out of either profile-cleanup gate.""" From acfc1c8639e56b05cfcf770c37590aeb004aeaf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:02:21 -0700 Subject: [PATCH 06/10] test(browser): cover MV3 restart teardown timeout --- ...st_mv3_restart_timeout_cleanup_contract.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/test_mv3_restart_timeout_cleanup_contract.py diff --git a/tests/test_mv3_restart_timeout_cleanup_contract.py b/tests/test_mv3_restart_timeout_cleanup_contract.py new file mode 100644 index 000000000..22eef6292 --- /dev/null +++ b/tests/test_mv3_restart_timeout_cleanup_contract.py @@ -0,0 +1,45 @@ +"""Regression contract for MV3 restart teardown timeout cleanup evidence.""" + +from __future__ import annotations + +import pathlib +import runpy +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class Mv3RestartTimeoutCleanupContractTests(unittest.TestCase): + """Require reviewed ChromeDriver teardown timeouts to retain MV3 cleanup proof.""" + + def test_teardown_timeout_returns_profile_cleanup_evidence(self) -> None: + """A bounded process teardown timeout must become failed-trial evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_restart_timeout_cleanup") + run_trial = namespace["_run_restart_trial"] + + def timeout_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: + raise subprocess.TimeoutExpired( + cmd="private-controlled-chromedriver-path", + timeout=5, + ) + + run_trial.__globals__["_run_browser_pass"] = timeout_browser_pass + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 12, + ) + + self.assertEqual(result["trial_number"], 12) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "TimeoutExpired") + self.assertIs(result["profile_cleaned"], True) + self.assertNotIn("private-controlled-chromedriver-path", repr(result)) + + +if __name__ == "__main__": + unittest.main() From 493279e0cc59c99fdd5b0825e4f2b8bc69ce558f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:16:17 -0700 Subject: [PATCH 07/10] fix(browser): retain MV3 teardown timeout evidence --- scripts/ci/run_mv3_compatibility.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 39f0ca80c..4315e31d3 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -749,7 +749,13 @@ def _run_restart_trial( profile_dir, "persisted", ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: From e15a436e8a70125f08645b6a3447e85895b5b53d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:17:40 -0700 Subject: [PATCH 08/10] docs(changelog): record MV3 teardown timeout resilience --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76589775b..c6f25d650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added - Failed ordinary and forced-close Agent Task browser trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, and separate aggregate compatibility gates require cleanup proof from every trial rather than filtering unsuccessful trials out; this does not attest adversarial filesystem erasure, process termination, or arbitrary browser recovery. -- Failed Manifest V3 restart trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, successful trials record the same cleanup fact, and an aggregate compatibility gate requires teardown proof from every MV3 trial before repeatability acceptance without retaining exception messages; this does not attest adversarial filesystem erasure, browser-process termination, or cleanup outside the controlled temporary profile. +- Failed Manifest V3 restart trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, including reviewed ChromeDriver process-teardown `TimeoutExpired` failures; successful trials record the same cleanup fact, and an aggregate compatibility gate requires teardown proof from every MV3 trial before repeatability acceptance without retaining exception messages or command paths; this does not attest adversarial filesystem erasure, browser-process termination, or cleanup outside the controlled temporary profile. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. From 645a71017c4603884da4cc6866e4e2a79c72e313 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 18:12:40 -0700 Subject: [PATCH 09/10] test(browser): reproduce surface-failure cleanup misclassification --- ...st_mv3_failure_profile_cleanup_contract.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_mv3_failure_profile_cleanup_contract.py b/tests/test_mv3_failure_profile_cleanup_contract.py index 730c44371..759214d17 100644 --- a/tests/test_mv3_failure_profile_cleanup_contract.py +++ b/tests/test_mv3_failure_profile_cleanup_contract.py @@ -39,6 +39,41 @@ def fail_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: self.assertIs(result["profile_cleaned"], True) self.assertNotIn("synthetic MV3 browser failure", repr(result)) + def test_surface_failure_preserves_cleanup_and_surface_evidence(self) -> None: + """A compatibility regression must not be mislabeled as profile cleanup failure.""" + + namespace = self._namespace("mv3_surface_failure_cleanup_behavior") + run_trial = namespace["_run_restart_trial"] + browser_pass_number = 0 + + def browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: + nonlocal browser_pass_number + browser_pass_number += 1 + persisted = browser_pass_number == 2 + return { + "browser_version": "controlled-browser", + "worker_start_count": browser_pass_number, + "storage_persistence": "persisted" if persisted else "initialized", + "surfaces": { + "service-worker": True, + "real-browser-click": not persisted, + }, + } + + run_trial.__globals__["_run_browser_pass"] = browser_pass + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 12, + ) + + self.assertEqual(result["trial_number"], 12) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "CompatibilitySurfaceFailure") + self.assertIs(result["profile_cleaned"], True) + self.assertIs(result["surfaces"]["real-browser-click"], False) + def test_acceptance_gate_requires_cleanup_evidence_for_every_mv3_trial(self) -> None: """Failed MV3 trials must not be filtered out of the profile-cleanup gate.""" From 4fc623effbeaaa50963fec7c370671ca12bceee9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 18:17:40 -0700 Subject: [PATCH 10/10] fix(browser): preserve cleanup evidence on MV3 surface failure --- scripts/ci/run_mv3_compatibility.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 4315e31d3..9f87e1e84 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -787,7 +787,14 @@ def _run_restart_trial( } ) if not all(surfaces.values()): - raise RuntimeError(f"compatibility surface failed in trial {trial_number}") + return { + "trial_number": trial_number, + "passed": False, + "failure_type": "CompatibilitySurfaceFailure", + "surfaces": surfaces, + "profile_cleaned": True, + "duration_ms": duration_ms, + } return { "trial_number": trial_number,