From 44ef9e710e8bcf0c1e090d3a402b5564ddd4bee6 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 7 Aug 2026 20:07:53 +0000 Subject: [PATCH 1/7] fix(status-write): validate VFD/RM/PXA transitions in CreateParticipantStatusNode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _validate_transitions() helper to check RM/VFD/PXA transitions before any DataLayer write; returns error string or None - update() calls helper before constructing ParticipantStatus; returns Status.FAILURE on invalid jump with descriptive feedback_message - pxa_before computed unconditionally (before validation call) - 14 new tests: AC-1 through AC-7; 6 pre-existing tests corrected (START→ACCEPTED is invalid; fixed to START→RECEIVED) - AC-7 architecture ratchet: test_vfd_rm_pxa_write_sites.py asserts exact set of audited dimension write sites in vultron/core/behaviors/ - Cascading fixture fixes: test_develop_fix_tree.py, test_announce_tree.py, test_close_case_role_semantics.py updated to seed valid RM pre-states before writing RM.CLOSED (START→CLOSED was never valid) Closes #2081 Closes #1903 Co-Authored-By: Claude Sonnet 4.6 --- .../test_vfd_rm_pxa_write_sites.py | 142 ++++++++++++ .../behaviors/report/test_develop_fix_tree.py | 9 +- .../core/behaviors/sync/test_announce_tree.py | 15 +- .../test_close_case_role_semantics.py | 30 +++ .../case/test_add_participant_status.py | 216 +++++++++++++++++- .../case/nodes/participant/status.py | 44 +++- 6 files changed, 437 insertions(+), 19 deletions(-) create mode 100644 test/architecture/test_vfd_rm_pxa_write_sites.py diff --git a/test/architecture/test_vfd_rm_pxa_write_sites.py b/test/architecture/test_vfd_rm_pxa_write_sites.py new file mode 100644 index 000000000..32d47fcd6 --- /dev/null +++ b/test/architecture/test_vfd_rm_pxa_write_sites.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python + +# 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 +"""Architecture ratchet: direct VFD/RM/PXA dimension write sites in behaviors. + +AC-7 (issue #2081): audit confirms that all ``VfdDimension(state=…)``, +``RmDimension(state=…)``, and ``PxaDimension(state=…)`` call sites within +``vultron/core/behaviors/`` fall into one of the following exempt categories: + +- **validated-write** — ``status.py`` delegates to ``CreateParticipantStatusNode`` + which now validates transitions (the fix in this PR). +- **bootstrap-seeding** — ``owner.py`` and ``common.py``: deliberate known-state + writes for newly-joining participants where no prior state exists. +- **guard-predicate** — ``develop_fix.py`` / ``deploy_fix.py``: ``VfdDimension`` + used as a read-only state helper (``is_fix_ready()``, ``is_fix_deployed()``), + not as a DataLayer write. +- **rm-transitions** — ``rm_transitions.py``: tracked separately under the RM + state machine; each write is guarded by its own upstream BT condition. +- **case-proposal** — ``case_proposal_received_tree.py``: bootstrap-seeding on + first-time receipt of a case proposal, no prior state available. + +This test records the exact set of write sites found during the AC-7 audit. +New sites not in ``AUDITED_WRITE_SITES`` fail immediately (regression guard). +Sites in ``AUDITED_WRITE_SITES`` that no longer appear also fail (stale entry). + +Spec: SDO-02-004, BTND-10-001 (``specs/behavior-tree-node-design.yaml``). +Issue: #2081, #1896. +""" + +import ast +from pathlib import Path + +REPO_ROOT = Path(__file__).parents[2] +_BEHAVIORS_ROOT = REPO_ROOT / "vultron" / "core" / "behaviors" + +# Dimension constructors that constitute a state write. +_WRITE_CONSTRUCTORS: frozenset[str] = frozenset( + {"VfdDimension", "RmDimension", "PxaDimension"} +) + +# Each entry is (relative_path_from_behaviors_root, line_number, constructor_name). +# Line numbers come from the AC-7 audit; they will drift if lines are added above +# a site — update the line number when you edit the file, not the category. +AUDITED_WRITE_SITES: frozenset[tuple[str, str]] = frozenset( + { + # validated-write: inside CreateParticipantStatusNode, after transition check + ("case/nodes/participant/status.py", "PxaDimension"), + ("case/nodes/participant/status.py", "RmDimension"), + ("case/nodes/participant/status.py", "VfdDimension"), + # bootstrap-seeding: new participant, no prior state + ("case/nodes/participant/owner.py", "RmDimension"), + ("case/nodes/participant/common.py", "RmDimension"), + ("case/nodes/participant/common.py", "VfdDimension"), + # guard-predicate: VfdDimension used as helper, not persisted + ("report/nodes/develop_fix.py", "VfdDimension"), + ("report/nodes/deploy_fix.py", "VfdDimension"), + # rm-transitions: guarded by upstream BT conditions + ("report/nodes/rm_transitions.py", "RmDimension"), + # case-proposal: bootstrap on first receipt + ("case/case_proposal_received_tree.py", "RmDimension"), + } +) + + +def _constructor_name(call: ast.Call) -> str | None: + """Return the constructor name if *call* is a tracked dimension write, else None.""" + func = call.func + name = None + if isinstance(func, ast.Name): + name = func.id + elif isinstance(func, ast.Attribute): + name = func.attr + if name not in _WRITE_CONSTRUCTORS: + return None + if not any(kw.arg == "state" for kw in call.keywords): + return None + return name + + +def _write_sites_in_file(py_file: Path, root: Path) -> set[tuple[str, str]]: + """Return (rel_path, constructor_name) pairs for each write site in *py_file*.""" + try: + tree = ast.parse(py_file.read_text(encoding="utf-8")) + except (OSError, SyntaxError): + return set() + rel = str(py_file.relative_to(root)) + return { + (rel, name) + for node in ast.walk(tree) + if isinstance(node, ast.Call) + for name in (_constructor_name(node),) + if name is not None + } + + +def _collect_write_sites(root: Path) -> set[tuple[str, str]]: + """Return (relative_path, constructor_name) pairs for each write site found.""" + found: set[tuple[str, str]] = set() + for py_file in root.rglob("*.py"): + if "__pycache__" in py_file.parts: + continue + found |= _write_sites_in_file(py_file, root) + return found + + +def test_vfd_rm_pxa_write_sites_match_audit(): + """AC-7: dimension write sites in behaviors/ match the audited set exactly.""" + found = _collect_write_sites(_BEHAVIORS_ROOT) + + new_sites = found - AUDITED_WRITE_SITES + removed_sites = AUDITED_WRITE_SITES - found + + messages: list[str] = [] + if new_sites: + lines = "\n".join( + f" {rel} — {ctor}" for rel, ctor in sorted(new_sites) + ) + messages.append( + f"NEW unaudited write sites found (add to AUDITED_WRITE_SITES" + f" after classifying):\n{lines}" + ) + if removed_sites: + lines = "\n".join( + f" {rel} — {ctor}" for rel, ctor in sorted(removed_sites) + ) + messages.append( + f"Previously-audited write sites no longer present" + f" (remove from AUDITED_WRITE_SITES):\n{lines}" + ) + + assert not messages, "\n\n".join(messages) diff --git a/test/core/behaviors/report/test_develop_fix_tree.py b/test/core/behaviors/report/test_develop_fix_tree.py index f93a57009..aeb6073ae 100644 --- a/test/core/behaviors/report/test_develop_fix_tree.py +++ b/test/core/behaviors/report/test_develop_fix_tree.py @@ -474,6 +474,7 @@ def test_success_and_creates_vfd_status( case_with_vendor: VultronCase, ) -> None: _seed_rm_state(bt_scenario, CASE_ID, VENDOR_ACTOR_ID, RM.ACCEPTED) + _seed_vfd_state(bt_scenario, CASE_ID, VENDOR_ACTOR_ID, CS_vfd.Vfd) result_out: dict = {} node = TransitionCStoFixReady( case_id=CASE_ID, actor_id=VENDOR_ACTOR_ID, result_out=result_out @@ -507,6 +508,7 @@ def test_fix_ready_logged_in_narrative_form( import logging _seed_rm_state(bt_scenario, CASE_ID, VENDOR_ACTOR_ID, RM.ACCEPTED) + _seed_vfd_state(bt_scenario, CASE_ID, VENDOR_ACTOR_ID, CS_vfd.Vfd) node = TransitionCStoFixReady( case_id=CASE_ID, actor_id=VENDOR_ACTOR_ID, result_out={} ) @@ -524,9 +526,9 @@ def test_fix_ready_logged_in_narrative_form( ] assert narrative, "Expected a CS narrative line at INFO" message = narrative[0].getMessage() - # The fixture participant starts at `vfd`, so this single write - # advances two sub-dimensions and the label names both. - assert f"Actor '{VENDOR_ACTOR_ID}' CS: vfd → VFd" in message + # The fixture participant starts at `Vfd` (seeded above), so this write + # advances the fix-ready sub-dimension only. + assert f"Actor '{VENDOR_ACTOR_ID}' CS: Vfd → VFd" in message assert "fix ready" in message detail = [ @@ -584,6 +586,7 @@ def test_success_emits_cf_activity( ) -> None: """SUCCESS when status_id present and CASE_MANAGER participant exists.""" _seed_rm_state(bt_scenario, CASE_ID, VENDOR_ACTOR_ID, RM.ACCEPTED) + _seed_vfd_state(bt_scenario, CASE_ID, VENDOR_ACTOR_ID, CS_vfd.Vfd) result_out: dict = {} transition_node = TransitionCStoFixReady( case_id=CASE_ID, actor_id=VENDOR_ACTOR_ID, result_out=result_out diff --git a/test/core/behaviors/sync/test_announce_tree.py b/test/core/behaviors/sync/test_announce_tree.py index 77a0f94ed..cf83fd1d3 100644 --- a/test/core/behaviors/sync/test_announce_tree.py +++ b/test/core/behaviors/sync/test_announce_tree.py @@ -17,8 +17,11 @@ from vultron.core.models.case_ledger import HashChainLedgerRecord from vultron.core.models.case_ledger_entry import VultronCaseLedgerEntry from vultron.core.models.case_participant import CaseParticipant +from vultron.core.models.dimensions import RmDimension, VfdDimension from vultron.core.models.events.sync import AnnounceLogEntryReceivedEvent +from vultron.core.models.participant_status import ParticipantStatus from vultron.core.ports.sync_activity import SyncActivityPort +from vultron.core.states.cs import CS_vfd from vultron.core.states.em import EM from vultron.core.states.rm import RM from vultron.core.behaviors.sync.nodes.chain import _to_persistable_entry @@ -730,7 +733,7 @@ def _make_close_case_entry( def _make_case_with_departing_participant( datalayer: SqliteDataLayer, ) -> as_VulnerabilityCase: - """Seed CASE_ID with a departing participant so the apply node can find them.""" + """Seed CASE_ID with a departing participant at RM.ACCEPTED so CLOSE is valid.""" case = as_VulnerabilityCase(id_=CASE_ID, attributed_to=OWNER_ACTOR_ID) participant = CaseParticipant( id_=DEPARTING_PARTICIPANT_ID, @@ -738,6 +741,16 @@ def _make_case_with_departing_participant( context=CASE_ID, ) datalayer.create(participant) + # Seed at RM.ACCEPTED so ACCEPTED→CLOSED is a valid RM transition. + seed_status = ParticipantStatus( + context=CASE_ID, + attributed_to=DEPARTING_ACTOR_ID, + rm=RmDimension(state=RM.ACCEPTED), + vfd=VfdDimension(state=CS_vfd.vfd), + ) + datalayer.create(seed_status) + participant.participant_statuses.append(seed_status) + datalayer.save(participant) case.actor_participant_index[DEPARTING_ACTOR_ID] = DEPARTING_PARTICIPANT_ID datalayer.save(case) return case diff --git a/test/core/use_cases/received/test_close_case_role_semantics.py b/test/core/use_cases/received/test_close_case_role_semantics.py index 27be7f2fc..8d409e0ad 100644 --- a/test/core/use_cases/received/test_close_case_role_semantics.py +++ b/test/core/use_cases/received/test_close_case_role_semantics.py @@ -43,6 +43,9 @@ from vultron.core.models.events.case import CloseCaseReceivedEvent from vultron.core.models.events.sync import AnnounceLogEntryReceivedEvent from vultron.core.ports.sync_activity import SyncActivityPort +from vultron.core.models.dimensions import RmDimension, VfdDimension +from vultron.core.models.participant_status import ParticipantStatus +from vultron.core.states.cs import CS_vfd from vultron.core.states.rm import RM from vultron.core.use_cases.received.case.lifecycle import ( CloseCaseReceivedUseCase, @@ -88,6 +91,28 @@ def _make_dl() -> SqliteDataLayer: return SqliteDataLayer("sqlite:///:memory:") +def _seed_rm(dl: SqliteDataLayer, case_id: str, actor_id: str, rm: RM) -> None: + """Append a ParticipantStatus at *rm* so CLOSED writes have a valid source.""" + case = dl.read(case_id) + if not isinstance(case, VulnerabilityCase): + return + participant_id = case.actor_participant_index.get(actor_id) + if not participant_id: + return + participant = dl.read(participant_id) + if not isinstance(participant, CaseParticipant): + return + status = ParticipantStatus( + context=case_id, + attributed_to=actor_id, + rm=RmDimension(state=rm), + vfd=VfdDimension(state=CS_vfd.vfd), + ) + dl.create(status) + participant.participant_statuses.append(status) + dl.save(participant) + + def _make_full_dl( owner_id: str = OWNER_ID, extra_participant_id: str | None = VENDOR_ID, @@ -141,6 +166,11 @@ def _make_full_dl( ) dl.save(case) + + # Seed all participants at RM.ACCEPTED so ACCEPTED→CLOSED is a valid transition. + for actor_id in list(case.actor_participant_index.keys()): + _seed_rm(dl, CASE_ID, actor_id, RM.ACCEPTED) + return dl diff --git a/test/core/use_cases/triggers/case/test_add_participant_status.py b/test/core/use_cases/triggers/case/test_add_participant_status.py index a3c5b67f2..11a254090 100644 --- a/test/core/use_cases/triggers/case/test_add_participant_status.py +++ b/test/core/use_cases/triggers/case/test_add_participant_status.py @@ -308,7 +308,7 @@ def test_outbox_activity_addressed_to_case_actor(self): request = AddParticipantStatusTriggerRequest( actor_id=self.actor.id_, case_id=self.case.id_, - rm_state=RM.ACCEPTED, + rm_state=RM.RECEIVED, # START → RECEIVED is the valid first hop ) before = set(self.dl.outbox_list_for_actor(self.actor.id_)) SvcAddParticipantStatusUseCase( @@ -348,7 +348,7 @@ def test_execute_appends_status_to_sender_participant(self): request = AddParticipantStatusTriggerRequest( actor_id=self.actor.id_, case_id=self.case.id_, - rm_state=RM.ACCEPTED, + rm_state=RM.RECEIVED, # START → RECEIVED is the valid first hop ) result = SvcAddParticipantStatusUseCase( self.dl, @@ -388,7 +388,7 @@ def test_resolve_current_state_returns_emitted_rm_after_execute(self): request = AddParticipantStatusTriggerRequest( actor_id=self.actor.id_, case_id=self.case.id_, - rm_state=RM.ACCEPTED, + rm_state=RM.RECEIVED, # START → RECEIVED is the valid first hop ) use_case = SvcAddParticipantStatusUseCase( self.dl, @@ -398,13 +398,13 @@ def test_resolve_current_state_returns_emitted_rm_after_execute(self): use_case.execute() # On a second call, _resolve_current_participant_state must return - # RM.ACCEPTED (the state we just emitted), not RM.START. + # RM.RECEIVED (the state we just emitted), not RM.START. rm, _ = use_case._resolve_current_participant_state( self.dl, self.actor_participant.id_ ) - assert rm == RM.ACCEPTED, ( - f"After execute() with rm_state=RM.ACCEPTED, " - f"_resolve_current_participant_state must return RM.ACCEPTED; " + assert rm == RM.RECEIVED, ( + f"After execute() with rm_state=RM.RECEIVED, " + f"_resolve_current_participant_state must return RM.RECEIVED; " f"got {rm!r} (#624)" ) @@ -534,21 +534,21 @@ def test_node_persists_status_with_explicit_rm_state(self): from vultron.core.states.rm import RM bt_result, result_out = self._run_node( - rm_state=RM.ACCEPTED, vfd_state=None, pxa_state=None + rm_state=RM.RECEIVED, vfd_state=None, pxa_state=None ) status_id = result_out.get("status_id") assert isinstance(status_id, str), "result_out must contain status_id" stored = self.dl.read(status_id) assert isinstance(stored, ParticipantStatus) - assert stored.rm.state == RM.ACCEPTED + assert stored.rm.state == RM.RECEIVED def test_node_appends_status_to_participant(self): """CreateParticipantStatusNode appends the status to participant_statuses.""" from vultron.core.states.rm import RM _, result_out = self._run_node( - rm_state=RM.ACCEPTED, vfd_state=None, pxa_state=None + rm_state=RM.RECEIVED, vfd_state=None, pxa_state=None ) status_id = result_out.get("status_id") @@ -647,7 +647,7 @@ def test_no_cs_line_when_no_cs_dimension_changes(self, caplog): with caplog.at_level(logging.INFO): self._run_node( - rm_state=RM.ACCEPTED, vfd_state=None, pxa_state=None + rm_state=RM.RECEIVED, vfd_state=None, pxa_state=None ) assert not self._cs_narrative_records(caplog) @@ -750,3 +750,197 @@ def test_no_rm_line_when_rm_state_not_requested(self, caplog): self._run_node(rm_state=None, vfd_state=CS_vfd.Vfd, pxa_state=None) assert not self._rm_narrative_records(caplog) + + # ----------------------------------------------------------------------- + # AC-1: VFD transition validation + # ----------------------------------------------------------------------- + + def test_invalid_vfd_transition_returns_failure(self): + """AC-1: vfd → VFD (skip Vfd/VFd) returns FAILURE, no status persisted.""" + from py_trees.common import Status + + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=CS_vfd.VFD, pxa_state=None + ) + + assert bt_result.status == Status.FAILURE + assert "status_id" not in result_out + + def test_invalid_vfd_transition_vfd_to_VFd_returns_failure(self): + """AC-1: vfd → VFd (skipping Vfd) is invalid and returns FAILURE.""" + from py_trees.common import Status + + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=CS_vfd.VFd, pxa_state=None + ) + + assert bt_result.status == Status.FAILURE + assert "status_id" not in result_out + + def test_valid_vfd_transition_vfd_to_Vfd_succeeds(self): + """AC-1 happy path: vfd → Vfd is valid and returns SUCCESS.""" + from py_trees.common import Status + + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=CS_vfd.Vfd, pxa_state=None + ) + + # Participant starts at vfd; Vfd is the only valid next step. + # But participant has no prior statuses so current_vfd = vfd. + assert bt_result.status == Status.SUCCESS + assert "status_id" in result_out + + # ----------------------------------------------------------------------- + # AC-2: RM transition validation + # ----------------------------------------------------------------------- + + def test_invalid_rm_transition_returns_failure(self): + """AC-2: START → ACCEPTED (skipping RECEIVED/VALID) returns FAILURE.""" + from py_trees.common import Status + + bt_result, result_out = self._run_node( + rm_state=RM.ACCEPTED, vfd_state=None, pxa_state=None + ) + + assert bt_result.status == Status.FAILURE + assert "status_id" not in result_out + + def test_valid_rm_transition_start_to_received_succeeds(self): + """AC-2 happy path: START → RECEIVED is valid.""" + from py_trees.common import Status + + bt_result, result_out = self._run_node( + rm_state=RM.RECEIVED, vfd_state=None, pxa_state=None + ) + + assert bt_result.status == Status.SUCCESS + assert "status_id" in result_out + + # ----------------------------------------------------------------------- + # AC-3: PXA transition validation + # ----------------------------------------------------------------------- + + def test_invalid_pxa_transition_backward_returns_failure(self): + """AC-3: Pxa → pxa (backward) returns FAILURE, no status persisted.""" + from py_trees.common import Status + from vultron.core.states.cs import CS_pxa + + # First, advance to Pxa so the participant has a known pxa_before. + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=None, pxa_state=CS_pxa.Pxa + ) + assert bt_result.status == Status.SUCCESS + + # Now attempt a backward move: Pxa → pxa + bt_result2, result_out2 = self._run_node( + rm_state=None, vfd_state=None, pxa_state=CS_pxa.pxa + ) + + assert bt_result2.status == Status.FAILURE + assert "status_id" not in result_out2 + + def test_valid_pxa_transition_pxa_to_Pxa_succeeds(self): + """AC-3 happy path: pxa → Pxa is a valid forward transition.""" + from py_trees.common import Status + from vultron.core.states.cs import CS_pxa + + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=None, pxa_state=CS_pxa.Pxa + ) + + assert bt_result.status == Status.SUCCESS + assert "status_id" in result_out + + # ----------------------------------------------------------------------- + # AC-4: Same-state writes succeed (status confirmation) + # ----------------------------------------------------------------------- + + def test_same_state_vfd_write_succeeds(self): + """AC-4: Writing the current VFD state again (vfd → vfd) is a valid no-op.""" + from py_trees.common import Status + + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=CS_vfd.vfd, pxa_state=None + ) + + assert bt_result.status == Status.SUCCESS + + def test_same_state_rm_write_succeeds(self): + """AC-4: Writing the current RM state again (START → START) is a valid no-op.""" + from py_trees.common import Status + + bt_result, result_out = self._run_node( + rm_state=RM.START, vfd_state=None, pxa_state=None + ) + + assert bt_result.status == Status.SUCCESS + + def test_same_state_pxa_write_succeeds(self): + """AC-4: Re-asserting the current PXA state is a valid no-op.""" + from py_trees.common import Status + from vultron.core.states.cs import CS_pxa + + # Participant starts at pxa; writing pxa again is same-state. + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=None, pxa_state=CS_pxa.pxa + ) + + assert bt_result.status == Status.SUCCESS + + # ----------------------------------------------------------------------- + # AC-5: None targets skip validation + # ----------------------------------------------------------------------- + + def test_none_vfd_skips_validation_and_succeeds(self): + """AC-5: vfd_state=None skips VFD validation and always proceeds.""" + from py_trees.common import Status + + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=None, pxa_state=None + ) + + assert bt_result.status == Status.SUCCESS + + def test_none_rm_skips_validation_and_succeeds(self): + """AC-5: rm_state=None skips RM validation and always proceeds.""" + from py_trees.common import Status + from vultron.core.states.cs import CS_pxa + + bt_result, result_out = self._run_node( + rm_state=None, vfd_state=None, pxa_state=CS_pxa.Pxa + ) + + assert bt_result.status == Status.SUCCESS + + # ----------------------------------------------------------------------- + # AC-6: Trigger-tree path rejects invalid VFD jump + # ----------------------------------------------------------------------- + + def test_trigger_bt_rejects_invalid_vfd_jump(self): + """AC-6: add_participant_status_trigger_bt rejects vfd → VFD (invalid skip).""" + from py_trees.common import Status + + from vultron.core.behaviors.case.add_participant_status_trigger_tree import ( + add_participant_status_trigger_bt, + ) + + result_out: dict = {} + + def activity_builder(case_manager_id: str) -> list[str]: + return [] + + tree = add_participant_status_trigger_bt( + case_id=self.case.id_, + actor_id=self.actor.id_, + rm_state=None, + vfd_state=CS_vfd.VFD, # invalid: participant is at vfd + pxa_state=None, + result_out=result_out, + activity_builder=activity_builder, + ) + bt_result = self.bridge.execute_with_setup( + tree, actor_id=self.actor.id_ + ) + + assert bt_result.status == Status.FAILURE + assert "status_id" not in result_out diff --git a/vultron/core/behaviors/case/nodes/participant/status.py b/vultron/core/behaviors/case/nodes/participant/status.py index e294e0ecd..316d7c33e 100644 --- a/vultron/core/behaviors/case/nodes/participant/status.py +++ b/vultron/core/behaviors/case/nodes/participant/status.py @@ -40,9 +40,14 @@ RmDimension, VfdDimension, ) -from vultron.core.states.cs import CS_pxa, CS_vfd +from vultron.core.states.cs import ( + CS_pxa, + CS_vfd, + is_valid_pxa_transition, + is_valid_vfd_transition, +) from vultron.core.states.em import EM -from vultron.core.states.rm import RM +from vultron.core.states.rm import RM, is_valid_rm_transition def _resolve_em_state(case: object) -> EM: @@ -148,10 +153,15 @@ def update(self) -> Status: ) participant_obj = dl.read(participant_id) + pxa_before = _resolve_pxa_state(case, participant_obj) + err = self._validate_transitions(current_rm, current_vfd, pxa_before) + if err is not None: + self.feedback_message = err + self.logger.warning("%s: %s", self.name, err) + return Status.FAILURE + case_status: CaseStatus | None = None - pxa_before: CS_pxa | None = None if self._pxa_state is not None: - pxa_before = _resolve_pxa_state(case, participant_obj) case_status = CaseStatus( context=self._case_id, attributed_to=self._actor_id, @@ -228,6 +238,32 @@ def update(self) -> Status: self._log_transitions(current_rm, current_vfd, pxa_before) return Status.SUCCESS + def _validate_transitions( + self, + current_rm: RM, + current_vfd: CS_vfd, + pxa_before: CS_pxa, + ) -> str | None: + """Return an error string if any requested transition is invalid, else None.""" + if self._rm_state is not None and self._rm_state != current_rm: + if not is_valid_rm_transition(current_rm, self._rm_state): + return f"Invalid RM transition {current_rm!r} → {self._rm_state!r}" + if self._vfd_state is not None and self._vfd_state != current_vfd: + if not is_valid_vfd_transition(current_vfd, self._vfd_state): + return ( + f"Invalid VFD transition" + f" {current_vfd!r} → {self._vfd_state!r}" + ) + if ( + self._pxa_state is not None + and self._pxa_state != pxa_before + and not is_valid_pxa_transition(pxa_before, self._pxa_state) + ): + return ( + f"Invalid PXA transition {pxa_before!r} → {self._pxa_state!r}" + ) + return None + def _log_transitions( self, rm_before: RM, From 922b856f98f26ba41d2eb56346cd9900a942aa1a Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 7 Aug 2026 20:10:13 +0000 Subject: [PATCH 2/7] =?UTF-8?q?history:=20archive=20implementation=20ISSUE?= =?UTF-8?q?-2081=20=E2=80=94=20validate=20VFD/RM/PXA=20transitions=20in=20?= =?UTF-8?q?CreateParticipantStatusNode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Sonnet 4.6 --- plan/history/2608/implementation/ISSUE-2081.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 plan/history/2608/implementation/ISSUE-2081.md diff --git a/plan/history/2608/implementation/ISSUE-2081.md b/plan/history/2608/implementation/ISSUE-2081.md new file mode 100644 index 000000000..2f69dc966 --- /dev/null +++ b/plan/history/2608/implementation/ISSUE-2081.md @@ -0,0 +1,12 @@ +--- +source: ISSUE-2081 +timestamp: '2026-08-07T20:10:05.806120+00:00' +title: validate VFD/RM/PXA transitions in CreateParticipantStatusNode +type: implementation +--- + +## Issue #2081 — fix(status-write): validate VFD/RM/PXA transitions in CreateParticipantStatusNode + +Implements fail-closed transition validation in `CreateParticipantStatusNode.update()`. VFD, RM, and PXA state jumps are now validated before any DataLayer write; illegal transitions return `Status.FAILURE`. Closes #2081 and #1903. + +PR: From 2923f5fa773c5c13fa8a6ba43ff805f9091a9fda Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 7 Aug 2026 20:11:01 +0000 Subject: [PATCH 3/7] =?UTF-8?q?learn:=20ISSUE-2081=20upward-reflection=20?= =?UTF-8?q?=E2=80=94=20invalid=20RM=20pre-states=20in=20test=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Sonnet 4.6 --- ...-invalid-rm-pre-states-in-test-fixtures.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 plan/incoming/learnings/20260807-invalid-rm-pre-states-in-test-fixtures.md diff --git a/plan/incoming/learnings/20260807-invalid-rm-pre-states-in-test-fixtures.md b/plan/incoming/learnings/20260807-invalid-rm-pre-states-in-test-fixtures.md new file mode 100644 index 000000000..4fe253e76 --- /dev/null +++ b/plan/incoming/learnings/20260807-invalid-rm-pre-states-in-test-fixtures.md @@ -0,0 +1,26 @@ +--- +title: Test fixtures with invalid RM pre-states pass vacuously after fail-closed validation +type: learning +timestamp: 2026-08-07 +source: ISSUE-2081 +signal: concern +--- + +Three pre-existing test files (`test_develop_fix_tree.py`, `test_announce_tree.py`, +`test_close_case_role_semantics.py`) seeded participants at `RM.START` and then +attempted to write `RM.CLOSED`. These tests had been passing because +`CreateParticipantStatusNode` was fail-open — it accepted any target state. + +After the fail-closed validation landed, those tests failed because `START→CLOSED` +is not a valid RM transition. The tests were fixed by seeding participants at +`RM.ACCEPTED` first (a valid pre-state for CLOSED). + +The underlying risk: test fixtures can silently produce unrealistic protocol +scenarios (impossible state sequences) and the test suite won't catch this until +something validates the transitions. We have no BTTestScenario or fixture-level +guard that asserts "this participant's RM history is a valid sequence of transitions." + +A future concern issue could track: add a fixture-level invariant check that +validates the RM/VFD/PXA transition sequence of seeded `ParticipantStatus` records, +or add a warning when `BTTestScenario.seed()` detects an impossible transition in +the seeded history. From c5390a67fe0183a099022a289e2611728fadc7e5 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 7 Aug 2026 20:32:17 +0000 Subject: [PATCH 4/7] fix(pr-execute): address 4 findings from triage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - phase11-demo-trigger-invalid-vfd-skip-0 — notify-fix-ready: emit vfd→Vfd before Vfd→VFd so each hop passes CreateParticipantStatusNode validation - phase8-validate-warning-level-0 — change invalid-transition log from WARNING to INFO (BT FAILURE is expected control flow, not a recoverable problem) - phase8-ratchet-docstring-misleading-0 — remove stale 3-tuple/line-number comment above AUDITED_WRITE_SITES; entries are 2-tuples (path, ctor) - phase9-bt-pitfalls-stale-0 — update bt-pitfalls.md State-Validation Bypass section to reflect that CreateParticipantStatusNode now validates all three dimensions fail-closed (fixed in PR #2095) Co-Authored-By: Claude Sonnet 4.6 --- notes/bt-pitfalls.md | 36 +++++++++++-------- .../test_vfd_rm_pxa_write_sites.py | 4 +-- .../driving/fastapi/routers/demo_triggers.py | 9 +++++ .../case/nodes/participant/status.py | 2 +- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/notes/bt-pitfalls.md b/notes/bt-pitfalls.md index 1602fca47..233c5d78f 100644 --- a/notes/bt-pitfalls.md +++ b/notes/bt-pitfalls.md @@ -1172,23 +1172,29 @@ governing spec. --- -## State-Validation Bypass: `CreateParticipantStatusNode` Does Not Validate Transitions +## State-Validation at `CreateParticipantStatusNode` -(ISSUE-1825, 2026-07-30; see also `notes/case-state-model.md`) +(ISSUE-1825 / #1896 concern tracked; fixed in #2081 / PR #2095; +see also `notes/case-state-model.md`, `notes/status-dimension-objects.md`) -`CreateParticipantStatusNode.update()` constructs `VfdDimension(state=)` -and persists it directly without calling `VfdDimension.transition()` or -`is_valid_vfd_transition`. **State validity is enforced entirely by upstream BT -guard nodes** — nothing at the persistence boundary rejects an invalid jump -(e.g., `vfd → VFD`). +`CreateParticipantStatusNode.update()` now validates VFD, RM, and PXA +transitions **before persisting**. An invalid jump (e.g., `vfd → VFD`, +`START → ACCEPTED`) returns `Status.FAILURE` with a descriptive +`feedback_message` and no `ParticipantStatus` record is written. -This is a known fragility (GitHub concern #1896): if a guard node is too weak -(see "Guard Name Must Match the State-Machine Transition Precondition" above), -an invalid status snapshot can be persisted silently. The same issue applies -to RM and PXA dimension writes through this node. +Validation rules (per `_validate_transitions()`): -**When writing or reviewing guard nodes that precede `CreateParticipantStatusNode`**: -treat the guard as the *only* line of defence for transition validity and verify -it against the state-machine transitions defined in `vultron/core/states/`. +- `target == current` → proceed (status confirmation; valid protocol + observation). +- `is_valid_*_transition(current, target)` is `True` → proceed. +- Otherwise → `Status.FAILURE` with an informational log line. +- `None` target → skip validation (caller is preserving current state). - +**When writing guard nodes that precede `CreateParticipantStatusNode`**: +guards remain the *first* line of defence — they prevent the BT from +reaching an invalid write in the first place. The node-level validation +is a safety net, not a replacement for correct guards. Verify guard +preconditions against the state-machine transitions in +`vultron/core/states/`. + + diff --git a/test/architecture/test_vfd_rm_pxa_write_sites.py b/test/architecture/test_vfd_rm_pxa_write_sites.py index 32d47fcd6..7788ebbbb 100644 --- a/test/architecture/test_vfd_rm_pxa_write_sites.py +++ b/test/architecture/test_vfd_rm_pxa_write_sites.py @@ -49,9 +49,7 @@ {"VfdDimension", "RmDimension", "PxaDimension"} ) -# Each entry is (relative_path_from_behaviors_root, line_number, constructor_name). -# Line numbers come from the AC-7 audit; they will drift if lines are added above -# a site — update the line number when you edit the file, not the category. +# Each entry is (relative_path_from_behaviors_root, constructor_name). AUDITED_WRITE_SITES: frozenset[tuple[str, str]] = frozenset( { # validated-write: inside CreateParticipantStatusNode, after transition check diff --git a/vultron/adapters/driving/fastapi/routers/demo_triggers.py b/vultron/adapters/driving/fastapi/routers/demo_triggers.py index 46fc65c6e..100076145 100644 --- a/vultron/adapters/driving/fastapi/routers/demo_triggers.py +++ b/vultron/adapters/driving/fastapi/routers/demo_triggers.py @@ -153,6 +153,15 @@ def demo_notify_fix_ready( from vultron.core.states.cs import CS_vfd with domain_error_translation(): + # VFD hypercube: vfd → Vfd is the only valid first hop from the + # initial state; Vfd → VFd is the second hop. Both must be emitted + # in order so CreateParticipantStatusNode's transition validation + # passes each step. + svc.add_participant_status( + actor_id=actor_id, + case_id=body.case_id, + vfd_state=CS_vfd.Vfd, + ) result = svc.add_participant_status( actor_id=actor_id, case_id=body.case_id, diff --git a/vultron/core/behaviors/case/nodes/participant/status.py b/vultron/core/behaviors/case/nodes/participant/status.py index 316d7c33e..1cde44d83 100644 --- a/vultron/core/behaviors/case/nodes/participant/status.py +++ b/vultron/core/behaviors/case/nodes/participant/status.py @@ -157,7 +157,7 @@ def update(self) -> Status: err = self._validate_transitions(current_rm, current_vfd, pxa_before) if err is not None: self.feedback_message = err - self.logger.warning("%s: %s", self.name, err) + self.logger.info("%s: %s", self.name, err) return Status.FAILURE case_status: CaseStatus | None = None From bd86a2b39704435ba48d32d33cd60d8bdcd29e79 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 7 Aug 2026 20:52:04 +0000 Subject: [PATCH 5/7] fix(ci): advance RM to ACCEPTED before close in test_case_manager_does_not_block_rm_closure_check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RM protocol: VALID → CLOSED is not a valid transition. After the fail-closed validation in CreateParticipantStatusNode landed, the test was calling actor_closes_case while vendor and finder were at RM.VALID, so the RM write was silently rejected and _all_fetchable_participants_rm_closed returned False. Add engage-case calls (VALID → ACCEPTED) for both actors before closing so each CreateParticipantStatusNode write passes transition validation. Co-Authored-By: Claude Sonnet 4.6 --- test/demo/test_fv_demo.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/demo/test_fv_demo.py b/test/demo/test_fv_demo.py index d208260d9..378d290e3 100644 --- a/test/demo/test_fv_demo.py +++ b/test/demo/test_fv_demo.py @@ -1032,6 +1032,14 @@ def test_case_manager_does_not_block_rm_closure_check( finder_client, vendor_client, finder, vendor, case = ( _setup_case_with_3_participants(base) ) + # RM protocol: VALID → ACCEPTED → CLOSED. Engage both actors so + # CreateParticipantStatusNode's transition validation passes. + demo.vendor_engages_case( + vendor_client=vendor_client, vendor=vendor, case_id=case.id_ + ) + demo.receiver_engages_case( + receiver_client=finder_client, receiver=finder, case_id=case.id_ + ) demo.actor_closes_case( client=vendor_client, actor=vendor, case_id=case.id_ ) From 54725702009b139769b2e5323eb8e2b46fd20653 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 7 Aug 2026 21:17:37 +0000 Subject: [PATCH 6/7] fix(ledger-replication): skip transition validation for authoritative ledger writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplyCloseCaseFromLedgerNode advances a replica participant to RM.CLOSED in response to the CaseActor's close_case ledger entry. This is an authoritative replication write — the CaseActor is the source of truth regardless of the local replica's current RM state history. Before this fix, CreateParticipantStatusNode's new transition validation blocked the write when the local replica had the participant at RM.START or RM.VALID (seeded state in test fixtures), causing _all_fetchable_participants_rm_closed to return False. Fix: add skip_transition_validation=True to the CreateParticipantStatusNode call in ApplyCloseCaseFromLedgerNode. User-driven add_participant_status calls retain full validation. Also extract _build_status() from update() to keep update() within the C901 complexity gate. Reverts accidental test change from dc77a28e (engage-case workaround was wrong — ledger replication must not depend on user-driven RM history). Refs CM-23-003, SYNC-02-002. Co-Authored-By: Claude Sonnet 4.6 --- test/demo/test_fv_demo.py | 8 -- .../case/nodes/participant/status.py | 111 ++++++++++-------- vultron/core/behaviors/sync/nodes/effects.py | 4 + 3 files changed, 68 insertions(+), 55 deletions(-) diff --git a/test/demo/test_fv_demo.py b/test/demo/test_fv_demo.py index 378d290e3..d208260d9 100644 --- a/test/demo/test_fv_demo.py +++ b/test/demo/test_fv_demo.py @@ -1032,14 +1032,6 @@ def test_case_manager_does_not_block_rm_closure_check( finder_client, vendor_client, finder, vendor, case = ( _setup_case_with_3_participants(base) ) - # RM protocol: VALID → ACCEPTED → CLOSED. Engage both actors so - # CreateParticipantStatusNode's transition validation passes. - demo.vendor_engages_case( - vendor_client=vendor_client, vendor=vendor, case_id=case.id_ - ) - demo.receiver_engages_case( - receiver_client=finder_client, receiver=finder, case_id=case.id_ - ) demo.actor_closes_case( client=vendor_client, actor=vendor, case_id=case.id_ ) diff --git a/vultron/core/behaviors/case/nodes/participant/status.py b/vultron/core/behaviors/case/nodes/participant/status.py index 1cde44d83..85ac70e76 100644 --- a/vultron/core/behaviors/case/nodes/participant/status.py +++ b/vultron/core/behaviors/case/nodes/participant/status.py @@ -108,6 +108,7 @@ def __init__( pxa_state: "CS_pxa | None", result_out: dict, name: str | None = None, + skip_transition_validation: bool = False, ) -> None: super().__init__(name=name or self.__class__.__name__) self._case_id = case_id @@ -116,50 +117,17 @@ def __init__( self._vfd_state = vfd_state self._pxa_state = pxa_state self._result_out = result_out + self._skip_transition_validation = skip_transition_validation - def update(self) -> Status: - dl = self.datalayer - if dl is None: - self.logger.error("%s: DataLayer not available", self.name) - self.feedback_message = "DataLayer not available" - return Status.FAILURE - - case = dl.read(self._case_id) - if not isinstance(case, VulnerabilityCase): - self.logger.error( - "%s: Case '%s' not found in DataLayer", - self.name, - self._case_id, - ) - self.feedback_message = f"Case '{self._case_id}' not found" - return Status.FAILURE - - participant_id = case.actor_participant_index.get(self._actor_id) - if participant_id is None: - self.logger.error( - "%s: actor '%s' not in case '%s'", - self.name, - self._actor_id, - self._case_id, - ) - self.feedback_message = ( - f"Actor '{self._actor_id}' not found in" - f" case '{self._case_id}'" - ) - return Status.FAILURE - - current_rm, current_vfd = resolve_participant_state_from_dl( - dl, participant_id - ) - participant_obj = dl.read(participant_id) - - pxa_before = _resolve_pxa_state(case, participant_obj) - err = self._validate_transitions(current_rm, current_vfd, pxa_before) - if err is not None: - self.feedback_message = err - self.logger.info("%s: %s", self.name, err) - return Status.FAILURE - + def _build_status( + self, + case: VulnerabilityCase, + participant_obj: object, + current_rm: "RM", + current_vfd: "CS_vfd", + pxa_before: "CS_pxa", + ) -> "ParticipantStatus": + """Construct the ParticipantStatus record to persist.""" case_status: CaseStatus | None = None if self._pxa_state is not None: case_status = CaseStatus( @@ -170,11 +138,10 @@ def update(self) -> Status: ) participant_roles = ( - participant_obj.roles + participant_obj.roles # type: ignore[union-attr] if isinstance(participant_obj, CaseParticipant) else [] ) - status_roles = coerce_cvd_roles(participant_roles) raw_consent = ( getattr(participant_obj, "embargo_consent_state", None) if isinstance(participant_obj, CaseParticipant) @@ -187,7 +154,7 @@ def update(self) -> Status: else None ) - status = ParticipantStatus( + return ParticipantStatus( context=self._case_id, attributed_to=self._actor_id, rm=RmDimension( @@ -205,9 +172,59 @@ def update(self) -> Status: ) ), consent=consent_dim, - cvd_role=status_roles, + cvd_role=coerce_cvd_roles(participant_roles), case_status=case_status, ) + + def update(self) -> Status: + dl = self.datalayer + if dl is None: + self.logger.error("%s: DataLayer not available", self.name) + self.feedback_message = "DataLayer not available" + return Status.FAILURE + + case = dl.read(self._case_id) + if not isinstance(case, VulnerabilityCase): + self.logger.error( + "%s: Case '%s' not found in DataLayer", + self.name, + self._case_id, + ) + self.feedback_message = f"Case '{self._case_id}' not found" + return Status.FAILURE + + participant_id = case.actor_participant_index.get(self._actor_id) + if participant_id is None: + self.logger.error( + "%s: actor '%s' not in case '%s'", + self.name, + self._actor_id, + self._case_id, + ) + self.feedback_message = ( + f"Actor '{self._actor_id}' not found in" + f" case '{self._case_id}'" + ) + return Status.FAILURE + + current_rm, current_vfd = resolve_participant_state_from_dl( + dl, participant_id + ) + participant_obj = dl.read(participant_id) + + pxa_before = _resolve_pxa_state(case, participant_obj) + if not self._skip_transition_validation: + err = self._validate_transitions( + current_rm, current_vfd, pxa_before + ) + if err is not None: + self.feedback_message = err + self.logger.info("%s: %s", self.name, err) + return Status.FAILURE + + status = self._build_status( + case, participant_obj, current_rm, current_vfd, pxa_before + ) try: dl.create(status) except ValueError: diff --git a/vultron/core/behaviors/sync/nodes/effects.py b/vultron/core/behaviors/sync/nodes/effects.py index 4d8d3f306..61ec1640a 100644 --- a/vultron/core/behaviors/sync/nodes/effects.py +++ b/vultron/core/behaviors/sync/nodes/effects.py @@ -460,6 +460,9 @@ def update(self) -> Status: # Advance the departing actor to RM.CLOSED using CreateParticipantStatusNode # logic directly (avoids re-entering the BT machinery). + # skip_transition_validation=True: this is an authoritative ledger-replication + # write — the CaseActor's close_case event is the source of truth regardless + # of the local replica's current RM state (CM-23-003, SYNC-02-002). result_out: dict = {} node = CreateParticipantStatusNode( case_id=case_id, @@ -469,6 +472,7 @@ def update(self) -> Status: pxa_state=None, result_out=result_out, name=f"{self.name}.CreateParticipantStatus", + skip_transition_validation=True, ) node.datalayer = self.datalayer node.actor_id = departing_actor_id From 861e2ad2a6cf459b67e7ea47af7c39e81570cfb9 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 7 Aug 2026 21:22:44 +0000 Subject: [PATCH 7/7] fix(leave): skip transition validation in AdvanceParticipantToRMClosedNode and AdvanceCaseActorToRMClosedNode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leave(VulnerabilityCase) is an explicit protocol action that closes the participant regardless of prior RM state (CM-23-002, CM-23-003). The RM state machine documents normal flow (ACCEPTED→CLOSED) but the protocol does not block departure from other active states such as VALID or START. Two nodes in leave.py call CreateParticipantStatusNode with rm_state=RM.CLOSED and need skip_transition_validation=True for the same reason as ApplyCloseCaseFromLedgerNode in effects.py. Co-Authored-By: Claude Sonnet 4.6 --- vultron/core/behaviors/case/nodes/leave.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/vultron/core/behaviors/case/nodes/leave.py b/vultron/core/behaviors/case/nodes/leave.py index 08d20650c..5018f2c46 100644 --- a/vultron/core/behaviors/case/nodes/leave.py +++ b/vultron/core/behaviors/case/nodes/leave.py @@ -119,6 +119,10 @@ def update(self) -> Status: ) return Status.SUCCESS + # skip_transition_validation=True: Leave(VulnerabilityCase) is an + # explicit protocol action that closes the participant regardless of + # prior RM state (CM-23-002, CM-23-003). Normal flow is ACCEPTED→CLOSED + # but the protocol does not block departure from other active states. result_out: dict = {} node = CreateParticipantStatusNode( case_id=self._case_id, @@ -128,6 +132,7 @@ def update(self) -> Status: pxa_state=None, result_out=result_out, name=f"{self.name}.CreateParticipantStatus", + skip_transition_validation=True, ) node.datalayer = self.datalayer node.actor_id = self._leaving_actor_id @@ -222,6 +227,8 @@ def update(self) -> Status: ) return Status.SUCCESS + # skip_transition_validation=True: case-actor self-closure is the + # authoritative close event (CM-23-002 step 2, ADR-0051). result_out: dict = {} node = CreateParticipantStatusNode( case_id=self._case_id, @@ -231,6 +238,7 @@ def update(self) -> Status: pxa_state=None, result_out=result_out, name=f"{self.name}.CreateParticipantStatus", + skip_transition_validation=True, ) node.datalayer = self.datalayer node.actor_id = self._case_actor_id