From 6188336624268fb3e64a405b12159c92b1433536 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Sat, 8 Aug 2026 01:59:46 +0000 Subject: [PATCH 1/2] fix(demo): gate invite-path RM triage on Finder having case replica Bug #2120: run_invite_path_rm_triage fired before the Finder's VulnerabilityCase (genesis hash) was seeded, causing ReconstructChainTailNode to raise CLP-08-005 (unanchored chain bootstrap) in fcvcv and fvcv-handoff scenarios. Root cause: three call sites in _phase_report_submission, _phase_c2_suggests_v2, and _phase_coordinator_invites_vendor2 triggered RM state updates that broadcast Announce(CaseLedgerEntry) to the Finder before wait_for_case_on_container was called for the Finder container. Fix: - Add wait_for_case_on_container(finder_client, case.id_) immediately before run_invite_path_rm_triage at all three sites - Add finder_client parameter to _phase_c2_suggests_v2 and _phase_coordinator_invites_vendor2 (was missing from both signatures) - Add 5 regression tests (3 in new test_fcvcv_demo.py, 2 in test_fvcv_handoff_demo.py) verifying call ordering invariant Spec: CLP-08-005, SYNC-13 Co-Authored-By: Claude Sonnet 4.6 --- test/demo/test_fcvcv_demo.py | 293 +++++++++++++++++++++ test/demo/test_fvcv_handoff_demo.py | 130 +++++++++ vultron/demo/scenario/fcvcv_demo.py | 19 ++ vultron/demo/scenario/fvcv_handoff_demo.py | 11 + 4 files changed, 453 insertions(+) create mode 100644 test/demo/test_fcvcv_demo.py diff --git a/test/demo/test_fcvcv_demo.py b/test/demo/test_fcvcv_demo.py new file mode 100644 index 000000000..843a01a10 --- /dev/null +++ b/test/demo/test_fcvcv_demo.py @@ -0,0 +1,293 @@ +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""Regression tests for Bug #2120: CLP-08-005 unanchored chain bootstrap. + +The Finder receives Announce(CaseLedgerEntry) activities before its genesis +hash is seeded whenever run_invite_path_rm_triage fires without first +confirming the Finder has the case replica (SYNC-13, CLP-08-005). + +Each test verifies that wait_for_case_on_container(finder_client, case.id_) +is called BEFORE run_invite_path_rm_triage in every phase function that +triggers RM triage. +""" + +import contextlib +from unittest.mock import MagicMock, patch + +import pytest + +import vultron.demo.scenario.fcvcv_demo as demo + + +class _Helpers: + @staticmethod + def _actor(id_: str = "urn:test:actor"): + a = MagicMock() + a.id_ = id_ + return a + + @staticmethod + def _case(id_: str = "urn:test:case"): + c = MagicMock() + c.id_ = id_ + return c + + @staticmethod + def _client(): + c = MagicMock() + c.get.return_value = {} + return c + + +@pytest.mark.spec("CLP-08-005") +class TestFinderCaseReplicaWaitBeforeV1Triage(_Helpers): + """_phase_report_submission must wait for the Finder's case replica + before running V1's RM triage (Bug #2120).""" + + def test_finder_wait_before_v1_triage(self): + """wait_for_case_on_container(finder_client) precedes run_invite_path_rm_triage for V1.""" + finder_client = self._client() + c1_client = self._client() + v1_client = self._client() + c2_client = self._client() + v2_client = self._client() + + finder = self._actor("urn:test:finder") + c1 = self._actor("urn:test:c1") + c1_in_c1 = self._actor("urn:test:c1") + v1 = self._actor("urn:test:v1") + v1_in_v1 = self._actor("urn:test:v1") + c2 = self._actor("urn:test:c2") + c2_in_c2 = self._actor("urn:test:c2") + report = MagicMock() + offer = MagicMock() + offer.id_ = "urn:test:offer" + case = self._case("urn:test:case") + + call_order: list[str] = [] + + def _wait_for_case(client, case_id, **_kwargs): + if client is finder_client: + call_order.append("finder_wait") + + def _triage(**_kwargs): + call_order.append("triage") + + with ( + patch.object(demo, "reset_containers"), + patch.object( + demo, + "seed_containers_fcvcv", + return_value=(finder, c1, v1, c2, MagicMock()), + ), + patch.object( + demo, + "get_actor_by_id", + side_effect=[c1_in_c1, v1_in_v1, c2_in_c2], + ), + patch.object( + demo, "reporter_submits_report", return_value=(report, offer) + ), + patch.object(demo, "receiver_validates_report"), + patch.object(demo, "find_case_for_offer", return_value=case), + patch.object(demo, "receiver_engages_case"), + patch.object(demo, "wait_for_case_participants"), + patch.object(demo, "verify_case_active"), + patch.object( + demo, + "post_to_trigger", + return_value={ + "activity": {"id": "urn:test:act", "type": "Offer"} + }, + ), + patch.object(demo, "post_to_inbox_and_wait"), + patch.object(demo, "verify_object_stored"), + patch.object( + demo, "wait_for_case_on_container", side_effect=_wait_for_case + ), + patch.object( + demo, "run_invite_path_rm_triage", side_effect=_triage + ), + patch.object(demo, "as_TransitiveActivity") as mock_ta, + patch.object(demo, "as_VulnerabilityCase") as mock_vc, + patch.object( + demo, + "demo_check", + side_effect=lambda _: contextlib.nullcontext(), + ), + patch.object( + demo, + "demo_step", + side_effect=lambda _: contextlib.nullcontext(), + ), + ): + mock_ta.model_validate.return_value = MagicMock( + id_="urn:test:invite" + ) + mock_vc.model_validate.return_value = case + demo._phase_report_submission( + finder_client=finder_client, + c1_client=c1_client, + v1_client=v1_client, + c2_client=c2_client, + v2_client=v2_client, + finder_id=None, + c1_id=None, + v1_id=None, + c2_id=None, + v2_id=None, + ) + + # finder_wait must appear before the first triage call + assert ( + "finder_wait" in call_order + ), "wait_for_case_on_container(finder_client) was never called before V1 triage" + assert ( + "triage" in call_order + ), "run_invite_path_rm_triage was never called" + finder_idx = next( + i for i, v in enumerate(call_order) if v == "finder_wait" + ) + triage_idx = next(i for i, v in enumerate(call_order) if v == "triage") + assert finder_idx < triage_idx, ( + f"Finder case-replica wait (index {finder_idx}) must come BEFORE " + f"run_invite_path_rm_triage (index {triage_idx}). " + f"Call order: {call_order} — Bug #2120 (CLP-08-005)" + ) + + +@pytest.mark.spec("CLP-08-005") +class TestFinderCaseReplicaWaitBeforeV2Triage(_Helpers): + """_phase_c2_suggests_v2 must wait for the Finder's case replica + before running V2's RM triage (Bug #2120).""" + + def test_finder_client_in_signature(self): + """_phase_c2_suggests_v2 must accept finder_client as a parameter.""" + import inspect + + sig = inspect.signature(demo._phase_c2_suggests_v2) + assert "finder_client" in sig.parameters, ( + "_phase_c2_suggests_v2 must accept finder_client to gate V2 RM " + "triage on the Finder having the case replica (Bug #2120)" + ) + + def test_finder_wait_before_v2_triage(self): + """wait_for_case_on_container(finder_client) precedes run_invite_path_rm_triage for V2.""" + import inspect + + sig = inspect.signature(demo._phase_c2_suggests_v2) + if "finder_client" not in sig.parameters: + pytest.skip( + "finder_client not yet in signature — prerequisite missing" + ) + + finder_client = self._client() + c1_client = self._client() + c2_client = self._client() + v2_client = self._client() + c1_in_c1 = self._actor("urn:test:c1") + c2_in_c2 = self._actor("urn:test:c2") + v2 = self._actor("urn:test:v2") + case = self._case("urn:test:case") + + call_order: list[str] = [] + + def _wait_for_case(client, case_id, **_kwargs): + if client is finder_client: + call_order.append("finder_wait") + + def _triage(**_kwargs): + call_order.append("triage") + + with ( + patch.object( + demo, "wait_for_case_on_container", side_effect=_wait_for_case + ), + patch.object( + demo, "run_invite_path_rm_triage", side_effect=_triage + ), + patch.object( + demo, + "post_to_trigger", + return_value={ + "activity": {"id": "urn:test:act", "type": "Offer"} + }, + ), + patch.object(demo, "wait_for_case_participants"), + patch.object( + demo, + "find_cp_offer_for_case", + return_value="urn:test:cp-offer", + ), + patch.object( + demo, + "find_case_actor_participant_id", + return_value="urn:test:ca", + ), + patch.object( + demo, + "find_case_invite_for_actor", + return_value="urn:test:invite-id", + ), + patch.object( + demo, + "get_actor_by_id", + return_value=MagicMock(id_="urn:test:v2-in-v2"), + ), + patch.object(demo, "as_TransitiveActivity") as mock_ta, + patch.object(demo, "as_VulnerabilityCase") as mock_vc, + patch.object( + demo, + "demo_check", + side_effect=lambda _: contextlib.nullcontext(), + ), + patch.object( + demo, + "demo_step", + side_effect=lambda _: contextlib.nullcontext(), + ), + ): + mock_ta.model_validate.return_value = MagicMock( + id_="urn:test:invite" + ) + mock_vc.model_validate.return_value = case + demo._phase_c2_suggests_v2( + finder_client=finder_client, + c1_client=c1_client, + c2_client=c2_client, + v2_client=v2_client, + c1_in_c1=c1_in_c1, + c2_in_c2=c2_in_c2, + v2=v2, + case=case, + offer=MagicMock(id_="urn:test:offer"), + report=MagicMock(), + finder=self._actor("urn:test:finder"), + ) + + assert ( + "finder_wait" in call_order + ), "wait_for_case_on_container(finder_client) was never called before V2 triage" + assert ( + "triage" in call_order + ), "run_invite_path_rm_triage was never called" + finder_idx = next( + i for i, v in enumerate(call_order) if v == "finder_wait" + ) + triage_idx = next(i for i, v in enumerate(call_order) if v == "triage") + assert finder_idx < triage_idx, ( + f"Finder case-replica wait (index {finder_idx}) must come BEFORE " + f"run_invite_path_rm_triage (index {triage_idx}). " + f"Call order: {call_order} — Bug #2120 (CLP-08-005)" + ) diff --git a/test/demo/test_fvcv_handoff_demo.py b/test/demo/test_fvcv_handoff_demo.py index a94c115bc..7ba9992d6 100644 --- a/test/demo/test_fvcv_handoff_demo.py +++ b/test/demo/test_fvcv_handoff_demo.py @@ -841,3 +841,133 @@ def _is_ot_announce(a: object) -> bool: # runs after this fixture teardown, which is too late. monkeypatch.undo() reload_config() + + +# --------------------------------------------------------------------------- +# Bug #2120: finder case-replica must be seeded before Vendor2 RM triage +# --------------------------------------------------------------------------- + + +@pytest.mark.spec("CLP-08-005") +class TestFinderCaseReplicaWaitBeforeVendor2Triage: + """_phase_coordinator_invites_vendor2 must wait for the Finder's case + replica before running Vendor2's RM triage (Bug #2120).""" + + @staticmethod + def _actor(id_: str = "urn:test:actor"): + a = MagicMock() + a.id_ = id_ + return a + + @staticmethod + def _case(id_: str = "urn:test:case"): + c = MagicMock() + c.id_ = id_ + return c + + @staticmethod + def _client(): + c = MagicMock() + c.get.return_value = {} + return c + + def test_finder_client_in_signature(self): + """_phase_coordinator_invites_vendor2 must accept finder_client.""" + import inspect + + sig = inspect.signature(demo._phase_coordinator_invites_vendor2) + assert "finder_client" in sig.parameters, ( + "_phase_coordinator_invites_vendor2 must accept finder_client " + "to gate Vendor2 RM triage on the Finder having the case replica " + "(Bug #2120, CLP-08-005)" + ) + + def test_finder_wait_before_vendor2_triage(self): + """wait_for_case_on_container(finder_client) precedes run_invite_path_rm_triage for Vendor2.""" + import inspect + + sig = inspect.signature(demo._phase_coordinator_invites_vendor2) + if "finder_client" not in sig.parameters: + pytest.skip( + "finder_client not yet in signature — prerequisite missing" + ) + + finder_client = self._client() + vendor_client = self._client() + coordinator_client = self._client() + vendor2_client = self._client() + coordinator = self._actor("urn:test:coordinator") + coordinator_in_coordinator = self._actor("urn:test:coordinator") + vendor2 = self._actor("urn:test:vendor2") + vendor2_in_vendor2 = self._actor("urn:test:vendor2") + case = self._case("urn:test:case") + + call_order: list[str] = [] + + def _wait_for_case(client, case_id, **_kw): + if client is finder_client: + call_order.append("finder_wait") + + def _triage(**_kw): + call_order.append("triage") + + with ( + patch.object( + demo, "wait_for_case_on_container", side_effect=_wait_for_case + ), + patch.object( + demo, "run_invite_path_rm_triage", side_effect=_triage + ), + patch.object( + demo, + "post_to_trigger", + return_value={ + "activity": {"id": "urn:t:act", "type": "Offer"} + }, + ), + patch.object(demo, "find_case_invite_for_actor"), + patch.object(demo, "wait_for_case_participants"), + patch.object(demo, "as_TransitiveActivity") as mock_ta, + patch.object( + demo, + "demo_check", + side_effect=lambda _: __import__("contextlib").nullcontext(), + ), + patch.object( + demo, + "demo_step", + side_effect=lambda _: __import__("contextlib").nullcontext(), + ), + ): + mock_ta.model_validate.return_value = MagicMock(id_="urn:t:invite") + demo._phase_coordinator_invites_vendor2( + finder_client=finder_client, + vendor_client=vendor_client, + coordinator_client=coordinator_client, + vendor2_client=vendor2_client, + coordinator=coordinator, + coordinator_in_coordinator=coordinator_in_coordinator, + case_actor_id="urn:t:ca", + vendor2=vendor2, + vendor2_in_vendor2=vendor2_in_vendor2, + case=case, + offer=MagicMock(id_="urn:t:offer"), + report=MagicMock(), + finder=self._actor("urn:t:finder"), + ) + + assert ( + "finder_wait" in call_order + ), "wait_for_case_on_container(finder_client) was never called before Vendor2 triage" + assert ( + "triage" in call_order + ), "run_invite_path_rm_triage was never called" + finder_idx = next( + i for i, v in enumerate(call_order) if v == "finder_wait" + ) + triage_idx = next(i for i, v in enumerate(call_order) if v == "triage") + assert finder_idx < triage_idx, ( + f"Finder case-replica wait (index {finder_idx}) must come BEFORE " + f"run_invite_path_rm_triage (index {triage_idx}). " + f"Call order: {call_order} — Bug #2120 (CLP-08-005)" + ) diff --git a/vultron/demo/scenario/fcvcv_demo.py b/vultron/demo/scenario/fcvcv_demo.py index 6ce06ce1b..0a301a31a 100644 --- a/vultron/demo/scenario/fcvcv_demo.py +++ b/vultron/demo/scenario/fcvcv_demo.py @@ -292,6 +292,14 @@ def _phase_report_submission( case_id=case.id_, ) + with demo_check( + "Finder's DataLayer received case replica before V1 RM triage" + ): + wait_for_case_on_container( + client=finder_client, + case_id=case.id_, + ) + run_invite_path_rm_triage( invited_client=v1_client, invited_actor=v1_in_v1, @@ -376,6 +384,7 @@ def _phase_report_submission( def _phase_c2_suggests_v2( + finder_client: DataLayerClient, c1_client: DataLayerClient, c2_client: DataLayerClient, v2_client: DataLayerClient, @@ -479,6 +488,15 @@ def _phase_c2_suggests_v2( ) logger.info("✓ V2 joined case (6 participants)") + with demo_check( + "Finder's DataLayer received case replica before V2 RM triage" + ): + wait_for_case_on_container( + client=finder_client, + case_id=case.id_, + timeout_seconds=40.0, + ) + run_invite_path_rm_triage( invited_client=v2_client, invited_actor=v2_in_v2, @@ -1103,6 +1121,7 @@ def run_fcvcv_demo( ) _phase_c2_suggests_v2( + finder_client=finder_client, c1_client=c1_client, c2_client=c2_client, v2_client=v2_client, diff --git a/vultron/demo/scenario/fvcv_handoff_demo.py b/vultron/demo/scenario/fvcv_handoff_demo.py index 46cfd8079..624ab2cce 100644 --- a/vultron/demo/scenario/fvcv_handoff_demo.py +++ b/vultron/demo/scenario/fvcv_handoff_demo.py @@ -446,6 +446,7 @@ def _phase_ownership_handoff( def _phase_coordinator_invites_vendor2( + finder_client: DataLayerClient, vendor_client: DataLayerClient, coordinator_client: DataLayerClient, vendor2_client: DataLayerClient, @@ -521,6 +522,15 @@ def _phase_coordinator_invites_vendor2( ) logger.info("✓ Vendor2 joined case (%d participants)", 5) + with demo_check( + "Finder's DataLayer received case replica before Vendor2 RM triage" + ): + wait_for_case_on_container( + client=finder_client, + case_id=case.id_, + timeout_seconds=90.0, + ) + # CM-11-002: Vendor2 joined via invite-accept — run standard RM triage cycle. run_invite_path_rm_triage( invited_client=vendor2_client, @@ -1096,6 +1106,7 @@ def run_fvcv_handoff_demo( ) _phase_coordinator_invites_vendor2( + finder_client=finder_client, vendor_client=vendor_client, coordinator_client=coordinator_client, vendor2_client=vendor2_client, From 9892b762de14901a72a936ec1a6407b38f742450 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Sat, 8 Aug 2026 02:02:44 +0000 Subject: [PATCH 2/2] =?UTF-8?q?history:=20archive=20implementation=20ISSUE?= =?UTF-8?q?-2120=20=E2=80=94=20gate=20invite-path=20RM=20triage=20on=20Fin?= =?UTF-8?q?der=20having=20case=20replica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Sonnet 4.6 --- .../history/2608/implementation/ISSUE-2120.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 plan/history/2608/implementation/ISSUE-2120.md diff --git a/plan/history/2608/implementation/ISSUE-2120.md b/plan/history/2608/implementation/ISSUE-2120.md new file mode 100644 index 000000000..a66372ca2 --- /dev/null +++ b/plan/history/2608/implementation/ISSUE-2120.md @@ -0,0 +1,24 @@ +--- +source: ISSUE-2120 +timestamp: '2026-08-08T02:02:37.323601+00:00' +title: gate invite-path RM triage on Finder having case replica +type: implementation +--- + +Bug #2120: CLP-08-005 unanchored chain bootstrap in fcvcv and fvcv-handoff demos. + +Root cause: run_invite_path_rm_triage fired before Finder's VulnerabilityCase +(genesis hash) was seeded in DataLayer, causing ReconstructChainTailNode to +raise CLP-08-005 when processing Announce(CaseLedgerEntry). + +Fixed by adding wait_for_case_on_container(finder_client, case.id_) before each +of 3 run_invite_path_rm_triage call sites: + +- _phase_report_submission (V1 triage) in fcvcv_demo.py +- _phase_c2_suggests_v2 (V2 triage) in fcvcv_demo.py — also added finder_client param +- _phase_coordinator_invites_vendor2 (Vendor2 triage) in fvcv_handoff_demo.py — also added finder_client param + +5 regression tests added (3 in new test_fcvcv_demo.py, 2 in test_fvcv_handoff_demo.py) +verifying call-ordering invariant. + +PR: