diff --git a/notes/flaky-tests.md b/notes/flaky-tests.md index afc5ddc8d..f16016a5d 100644 --- a/notes/flaky-tests.md +++ b/notes/flaky-tests.md @@ -26,6 +26,8 @@ and fall through to Level 2 (GitHub label search). | Test node ID | Issue | Last blocked | |---|---|---| | `test/bt/test_vultrabot.py::MyTestCase::test_main` | — | 2026-05-05 | +| `test/ci/invariants/test_fv_invariants.py::test_invariant_5_expected_event_types_present[validate_report]` | #2274 | 2026-08-13 | +| `test/ci/invariants/test_fv_invariants.py::test_invariant_5_expected_event_types_present[engage_case]` | #2274 | 2026-08-13 | > Note: the two `test_integration_script_scenarios` entries were **hard-broken > on `main`, not flaky** — they failed deterministically. #2114 added a test that diff --git a/notes/structured-logging.md b/notes/structured-logging.md index df7e2af2e..c84a69b01 100644 --- a/notes/structured-logging.md +++ b/notes/structured-logging.md @@ -152,10 +152,11 @@ from the #1988 implementation: `TransitionRMtoValid`, …), reading the before-state from the latest `ParticipantStatus` and falling back to `RM.START`. `CreateParticipantStatusNode` is the second path — `leave.py`, - `sync/nodes/effects.py`, and `add_participant_status_trigger_tree.py` set - `rm_state=` on it directly without going through the helper — so it logs the - RM line itself. A new RM-writing node MUST route through one of these two, or - its transition will be missing from the INFO narrative. + `sync/nodes/close_case_effect.py` (`ApplyCloseCaseFromLedgerNode`), and + `add_participant_status_trigger_tree.py` set `rm_state=` on it directly + without going through the helper — so it logs the RM line itself. A new + RM-writing node MUST route through one of these two, or its transition will + be missing from the INFO narrative. - CS: `CreateParticipantStatusNode` is the shared writer for both VFD and PXA snapshots. `TransitionCStoFixReady` / `TransitionCStoFixDeployed` delegate to it and log only at DEBUG — they know the target state but not the origin. diff --git a/plan/history/2608/learning/CONCERN-2269.md b/plan/history/2608/learning/CONCERN-2269.md new file mode 100644 index 000000000..5a031cc5e --- /dev/null +++ b/plan/history/2608/learning/CONCERN-2269.md @@ -0,0 +1,40 @@ +--- +source: CONCERN-2269 +timestamp: '2026-08-13T01:00:42.254022+00:00' +title: append.py + effects.py decomposition eliminates BTND-07-004 churn +type: learning +--- + +Both `status/nodes/append.py` (499 lines) and `sync/nodes/effects.py` (495 lines) +were at the BTND-07-004 ceiling, causing any unrelated edit in those areas to +trigger a mandatory decomposition as a side-effect. + +**Resolution**: Decomposed both modules by semantic concern in a single PR with +no backward-compatibility shims. All importers updated in-place. + +## append.py → append/ subpackage + +- `append/conditions.py` — 4 guard/idempotency nodes + `_has_status_in_participant` helper +- `append/actions.py` — 3 DataLayer-mutating action nodes +- `append/__init__.py` — re-exports all 7 public names (import paths unchanged) + +## effects.py → per-class files + _helpers.py + +- `_helpers.py` — `_extract_id_from_field` + `_LedgerEffectNode` base class + (DRY: all 4 effect nodes shared identical `setup()` + `_require_log_entry` pattern) +- `participant_status_effect.py`, `note_effect.py`, `invite_accept_effect.py`, + `close_case_effect.py` — one file per class + +## Test mirroring + +- `test_append.py` split into `append/conftest.py` + `test_conditions.py` + `test_actions.py` +- Added `TestCheckParticipantRMNotClosedNode` (was missing from original) +- `test_effects.py` split into 4 per-class test files +- Added tests for `ApplyNoteFromLedgerNode`, `ApplyInviteAcceptFromLedgerNode`, + `ApplyCloseCaseFromLedgerNode` (all three were previously untested) + +**PR**: + +**5 other near-limit modules** noted as future work: `replay.py` (498), +`suggest_actor/emit.py` (498), `deploy_fix.py` (497), +`embargo/nodes/lifecycle.py` (494), `conditions.py` (488). diff --git a/test/core/behaviors/status/nodes/append/__init__.py b/test/core/behaviors/status/nodes/append/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/core/behaviors/status/nodes/append/conftest.py b/test/core/behaviors/status/nodes/append/conftest.py new file mode 100644 index 000000000..933fb8c97 --- /dev/null +++ b/test/core/behaviors/status/nodes/append/conftest.py @@ -0,0 +1,88 @@ +#!/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 + +"""Shared fixtures for append subpackage tests.""" + +import pytest +import py_trees + +from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer +from vultron.core.behaviors.bridge import BTBridge +from vultron.enums.roles import CVDRole +from vultron.wire.as2.vocab.objects.case_participant import as_CaseParticipant +from vultron.wire.as2.vocab.objects.case_status import as_ParticipantStatus +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + as_VulnerabilityCase, +) + +ACTOR_ID = "https://example.org/actors/vendor" +CASE_MANAGER_ID = "https://example.org/actors/case-actor" +CASE_ID = "https://example.org/cases/case-01" +PARTICIPANT_ID = "https://example.org/cases/case-01/participants/vendor" +CM_PARTICIPANT_ID = "https://example.org/cases/case-01/participants/case-actor" +STATUS_ID = "https://example.org/cases/case-01/statuses/s1" + + +@pytest.fixture(autouse=True) +def clear_blackboard(): + py_trees.blackboard.Blackboard.storage.clear() + + +@pytest.fixture +def dl(): + return SqliteDataLayer("sqlite:///:memory:") + + +@pytest.fixture +def bridge(dl): + return BTBridge(datalayer=dl) + + +@pytest.fixture +def participant(): + return as_CaseParticipant( + id_=PARTICIPANT_ID, + context=CASE_ID, + attributed_to=ACTOR_ID, + case_roles=[CVDRole.CASE_OWNER], + ) + + +@pytest.fixture +def status_obj(): + return as_ParticipantStatus(id_=STATUS_ID, context=CASE_ID) + + +@pytest.fixture +def populated_dl(dl, participant, status_obj): + case_manager_participant = as_CaseParticipant( + id_=CM_PARTICIPANT_ID, + context=CASE_ID, + attributed_to=CASE_MANAGER_ID, + case_roles=[CVDRole.CASE_MANAGER], + ) + case = as_VulnerabilityCase(id_=CASE_ID, name="Test Case") + case.add_participant(participant) + case.add_participant(case_manager_participant) + dl.create(case) + dl.create(participant) + dl.create(case_manager_participant) + dl.create(status_obj) + return dl + + +@pytest.fixture +def populated_bridge(populated_dl): + return BTBridge(datalayer=populated_dl) diff --git a/test/core/behaviors/status/nodes/append/test_actions.py b/test/core/behaviors/status/nodes/append/test_actions.py new file mode 100644 index 000000000..c7937a120 --- /dev/null +++ b/test/core/behaviors/status/nodes/append/test_actions.py @@ -0,0 +1,121 @@ +#!/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 + +"""Tests for append/actions.py: load, resolve, and append action nodes.""" + +import py_trees +from py_trees.common import Status + +from vultron.core.behaviors.status.nodes.append import ( + AppendStatusAndSaveParticipantNode, + LoadParticipantNode, + ResolveAndPersistStatusObjectNode, +) + +from .conftest import ACTOR_ID, PARTICIPANT_ID, STATUS_ID + +# --------------------------------------------------------------------------- +# LoadParticipantNode +# --------------------------------------------------------------------------- + + +class TestLoadParticipantNode: + def test_loads_participant_to_blackboard(self, populated_bridge): + node = LoadParticipantNode(participant_id=PARTICIPANT_ID) + result = populated_bridge.execute_with_setup( + tree=node, actor_id=ACTOR_ID + ) + assert result.status == Status.SUCCESS + + def test_missing_participant_fails(self, bridge): + node = LoadParticipantNode( + participant_id="https://example.org/cases/missing/p" + ) + result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) + assert result.status == Status.FAILURE + + +# --------------------------------------------------------------------------- +# ResolveAndPersistStatusObjectNode +# --------------------------------------------------------------------------- + + +class TestResolveAndPersistStatusObjectNode: + def test_resolves_from_dl(self, populated_bridge): + node = ResolveAndPersistStatusObjectNode( + status_id=STATUS_ID, status_obj_fallback=None + ) + result = populated_bridge.execute_with_setup( + tree=node, actor_id=ACTOR_ID + ) + assert result.status == Status.SUCCESS + + def test_missing_without_fallback_fails(self, bridge): + node = ResolveAndPersistStatusObjectNode( + status_id="https://example.org/missing", status_obj_fallback=None + ) + result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) + assert result.status == Status.FAILURE + + def test_uses_fallback_when_missing_from_dl(self, bridge): + """Fallback object is persisted and resolved when ID absent from DL.""" + from vultron.wire.as2.vocab.objects.case_status import ( + as_ParticipantStatus, + ) + from .conftest import CASE_ID + + fallback = as_ParticipantStatus(id_=STATUS_ID, context=CASE_ID) + node = ResolveAndPersistStatusObjectNode( + status_id=STATUS_ID, status_obj_fallback=fallback + ) + result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) + assert result.status == Status.SUCCESS + + +# --------------------------------------------------------------------------- +# AppendStatusAndSaveParticipantNode +# --------------------------------------------------------------------------- + + +class TestAppendStatusAndSaveParticipantNode: + def test_appends_status(self, populated_bridge, populated_dl): + p_before = populated_dl.read(PARTICIPANT_ID) + initial_count = len(p_before.participant_statuses) + + load = LoadParticipantNode(participant_id=PARTICIPANT_ID) + resolve = ResolveAndPersistStatusObjectNode( + status_id=STATUS_ID, status_obj_fallback=None + ) + append = AppendStatusAndSaveParticipantNode( + status_id=STATUS_ID, participant_id=PARTICIPANT_ID + ) + seq = py_trees.composites.Sequence( + name="TestSeq", memory=False, children=[load, resolve, append] + ) + result = populated_bridge.execute_with_setup( + tree=seq, actor_id=ACTOR_ID + ) + assert result.status == Status.SUCCESS + + p = populated_dl.read(PARTICIPANT_ID) + assert len(p.participant_statuses) == initial_count + 1 + + def test_missing_blackboard_data_fails(self, bridge): + """No prior load/resolve on blackboard → FAILURE.""" + append = AppendStatusAndSaveParticipantNode( + status_id=STATUS_ID, participant_id=PARTICIPANT_ID + ) + result = bridge.execute_with_setup(tree=append, actor_id=ACTOR_ID) + assert result.status == Status.FAILURE diff --git a/test/core/behaviors/status/nodes/test_append.py b/test/core/behaviors/status/nodes/append/test_conditions.py similarity index 60% rename from test/core/behaviors/status/nodes/test_append.py rename to test/core/behaviors/status/nodes/append/test_conditions.py index 508a0dce9..4908cd75a 100644 --- a/test/core/behaviors/status/nodes/test_append.py +++ b/test/core/behaviors/status/nodes/append/test_conditions.py @@ -13,7 +13,7 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -"""Unit tests for append-participant-status leaf nodes. +"""Tests for append/conditions.py: idempotency guards and RM validation. Tests SkipIfIdempotentNode, LoadParticipantNode, CheckStatusNotAlreadyAppendedNode, ResolveAndPersistStatusObjectNode and @@ -23,14 +23,12 @@ Per DEMOMA-07-003 step 2. """ -import pytest import py_trees from py_trees.common import Status -from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer from vultron.core.behaviors.bridge import BTBridge from vultron.core.behaviors.status.nodes.append import ( - AppendStatusAndSaveParticipantNode, + CheckParticipantRMNotClosedNode, CheckStatusNotAlreadyAppendedNode, LoadParticipantNode, ResolveAndPersistStatusObjectNode, @@ -39,73 +37,10 @@ from vultron.core.behaviors.status.nodes.rm_validation import ( ValidateRMTransitionNode, ) -from vultron.enums.roles import CVDRole -from vultron.wire.as2.vocab.objects.case_participant import as_CaseParticipant +from vultron.core.states.rm import RM from vultron.wire.as2.vocab.objects.case_status import as_ParticipantStatus -from vultron.wire.as2.vocab.objects.vulnerability_case import ( - as_VulnerabilityCase, -) - -ACTOR_ID = "https://example.org/actors/vendor" -CASE_MANAGER_ID = "https://example.org/actors/case-actor" -CASE_ID = "https://example.org/cases/case-01" -PARTICIPANT_ID = "https://example.org/cases/case-01/participants/vendor" -CM_PARTICIPANT_ID = "https://example.org/cases/case-01/participants/case-actor" -STATUS_ID = "https://example.org/cases/case-01/statuses/s1" - - -@pytest.fixture(autouse=True) -def clear_blackboard(): - py_trees.blackboard.Blackboard.storage.clear() - - -@pytest.fixture -def dl(): - return SqliteDataLayer("sqlite:///:memory:") - - -@pytest.fixture -def bridge(dl): - return BTBridge(datalayer=dl) - - -@pytest.fixture -def participant(): - return as_CaseParticipant( - id_=PARTICIPANT_ID, - context=CASE_ID, - attributed_to=ACTOR_ID, - case_roles=[CVDRole.CASE_OWNER], - ) - - -@pytest.fixture -def status_obj(): - return as_ParticipantStatus(id_=STATUS_ID, context=CASE_ID) - - -@pytest.fixture -def populated_dl(dl, participant, status_obj): - case_manager_participant = as_CaseParticipant( - id_=CM_PARTICIPANT_ID, - context=CASE_ID, - attributed_to=CASE_MANAGER_ID, - case_roles=[CVDRole.CASE_MANAGER], - ) - case = as_VulnerabilityCase(id_=CASE_ID, name="Test Case") - case.add_participant(participant) - case.add_participant(case_manager_participant) - dl.create(case) - dl.create(participant) - dl.create(case_manager_participant) - dl.create(status_obj) - return dl - - -@pytest.fixture -def populated_bridge(populated_dl): - return BTBridge(datalayer=populated_dl) +from .conftest import ACTOR_ID, CASE_ID, PARTICIPANT_ID, STATUS_ID # --------------------------------------------------------------------------- # SkipIfIdempotentNode @@ -146,27 +81,6 @@ def test_already_appended_succeeds(self, populated_dl): assert result.status == Status.SUCCESS -# --------------------------------------------------------------------------- -# LoadParticipantNode -# --------------------------------------------------------------------------- - - -class TestLoadParticipantNode: - def test_loads_participant_to_blackboard(self, populated_bridge): - node = LoadParticipantNode(participant_id=PARTICIPANT_ID) - result = populated_bridge.execute_with_setup( - tree=node, actor_id=ACTOR_ID - ) - assert result.status == Status.SUCCESS - - def test_missing_participant_fails(self, bridge): - node = LoadParticipantNode( - participant_id="https://example.org/cases/missing/p" - ) - result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) - assert result.status == Status.FAILURE - - # --------------------------------------------------------------------------- # CheckStatusNotAlreadyAppendedNode # --------------------------------------------------------------------------- @@ -204,29 +118,6 @@ def test_already_appended_fails(self, populated_dl): assert result.status == Status.FAILURE -# --------------------------------------------------------------------------- -# ResolveAndPersistStatusObjectNode -# --------------------------------------------------------------------------- - - -class TestResolveAndPersistStatusObjectNode: - def test_resolves_from_dl(self, populated_bridge): - node = ResolveAndPersistStatusObjectNode( - status_id=STATUS_ID, status_obj_fallback=None - ) - result = populated_bridge.execute_with_setup( - tree=node, actor_id=ACTOR_ID - ) - assert result.status == Status.SUCCESS - - def test_missing_without_fallback_fails(self, bridge): - node = ResolveAndPersistStatusObjectNode( - status_id="https://example.org/missing", status_obj_fallback=None - ) - result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) - assert result.status == Status.FAILURE - - # --------------------------------------------------------------------------- # ValidateRMTransitionNode # --------------------------------------------------------------------------- @@ -247,31 +138,122 @@ def test_valid_forward_transition_succeeds(self, populated_bridge): ) assert result.status == Status.SUCCESS + def test_no_current_status_succeeds(self, populated_bridge): + """Participant with no prior status passes transition validation.""" + resolve = ResolveAndPersistStatusObjectNode( + status_id=STATUS_ID, status_obj_fallback=None + ) + load = LoadParticipantNode(participant_id=PARTICIPANT_ID) + validate = ValidateRMTransitionNode(participant_id=PARTICIPANT_ID) + seq = py_trees.composites.Sequence( + name="TestSeq", + memory=False, + children=[load, resolve, validate], + ) + result = populated_bridge.execute_with_setup( + tree=seq, actor_id=ACTOR_ID + ) + assert result.status == Status.SUCCESS -# --------------------------------------------------------------------------- -# AppendStatusAndSaveParticipantNode -# --------------------------------------------------------------------------- + def test_backwards_transition_fails(self, populated_dl): + """A status with CLOSED RM on a participant already CLOSED → FAILURE.""" + # Build a status with RM.CLOSED and append it to the participant. + from vultron.wire.as2.vocab.objects.case_status import ( + as_ParticipantStatus, + ) + closed_status_id = "https://example.org/cases/case-01/statuses/closed" + closed_status = as_ParticipantStatus( + id_=closed_status_id, + context=CASE_ID, + rm_state=RM.CLOSED, + ) + populated_dl.create(closed_status) -class TestAppendStatusAndSaveParticipantNode: - def test_appends_status(self, populated_bridge, populated_dl): - p_before = populated_dl.read(PARTICIPANT_ID) - initial_count = len(p_before.participant_statuses) + # Put participant in CLOSED — append CLOSED last so participant_status + # (= participant_statuses[-1]) reflects RM.CLOSED. + p = populated_dl.read(PARTICIPANT_ID) + p.participant_statuses.append(closed_status) + populated_dl.save(p) + # Now try to validate a new status transition — should fail since + # participant is already CLOSED. + bridge = BTBridge(datalayer=populated_dl) load = LoadParticipantNode(participant_id=PARTICIPANT_ID) resolve = ResolveAndPersistStatusObjectNode( status_id=STATUS_ID, status_obj_fallback=None ) - append = AppendStatusAndSaveParticipantNode( - status_id=STATUS_ID, participant_id=PARTICIPANT_ID - ) + validate = ValidateRMTransitionNode(participant_id=PARTICIPANT_ID) seq = py_trees.composites.Sequence( - name="TestSeq", memory=False, children=[load, resolve, append] + name="TestSeq", + memory=False, + children=[load, resolve, validate], ) - result = populated_bridge.execute_with_setup( - tree=seq, actor_id=ACTOR_ID + result = bridge.execute_with_setup(tree=seq, actor_id=ACTOR_ID) + assert result.status == Status.FAILURE + + +# --------------------------------------------------------------------------- +# CheckParticipantRMNotClosedNode +# --------------------------------------------------------------------------- + + +class TestCheckParticipantRMNotClosedNode: + def test_open_participant_succeeds(self, populated_dl): + """Participant not in CLOSED state → SUCCESS.""" + bridge = BTBridge(datalayer=populated_dl) + node = CheckParticipantRMNotClosedNode(participant_id=PARTICIPANT_ID) + result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) + assert result.status == Status.SUCCESS + + def test_missing_participant_succeeds(self, bridge): + """Participant not found in DataLayer → SUCCESS (no terminal check).""" + node = CheckParticipantRMNotClosedNode( + participant_id="https://example.org/missing/participant" ) + result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) assert result.status == Status.SUCCESS + def test_closed_participant_fails(self, populated_dl): + """Participant already in RM.CLOSED without prior status match → FAILURE.""" + closed_status = as_ParticipantStatus( + id_="https://example.org/cases/case-01/statuses/c1", + context=CASE_ID, + rm_state=RM.CLOSED, + ) + populated_dl.create(closed_status) + p = populated_dl.read(PARTICIPANT_ID) + p.participant_statuses.append(closed_status) + populated_dl.save(p) + + bridge = BTBridge(datalayer=populated_dl) + node = CheckParticipantRMNotClosedNode( + participant_id=PARTICIPANT_ID, status_id=STATUS_ID + ) + result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) + assert result.status == Status.FAILURE + + def test_closed_participant_with_matching_status_succeeds( + self, populated_dl + ): + """Participant CLOSED but status already appended → SUCCESS (idempotent).""" + status = populated_dl.read(STATUS_ID) + closed_status = as_ParticipantStatus( + id_="https://example.org/cases/case-01/statuses/c1", + context=CASE_ID, + rm_state=RM.CLOSED, + ) + populated_dl.create(closed_status) p = populated_dl.read(PARTICIPANT_ID) - assert len(p.participant_statuses) == initial_count + 1 + # STATUS_ID appended first, then CLOSED last — so participant_status + # (= participant_statuses[-1]) is CLOSED, but STATUS_ID is present. + p.participant_statuses.append(status) + p.participant_statuses.append(closed_status) + populated_dl.save(p) + + bridge = BTBridge(datalayer=populated_dl) + node = CheckParticipantRMNotClosedNode( + participant_id=PARTICIPANT_ID, status_id=STATUS_ID + ) + result = bridge.execute_with_setup(tree=node, actor_id=ACTOR_ID) + assert result.status == Status.SUCCESS diff --git a/test/core/behaviors/sync/nodes/test_close_case_effect.py b/test/core/behaviors/sync/nodes/test_close_case_effect.py new file mode 100644 index 000000000..e69dca385 --- /dev/null +++ b/test/core/behaviors/sync/nodes/test_close_case_effect.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python +"""Tests for ApplyCloseCaseFromLedgerNode. + +Covers close_case ledger event advancing the departing actor to RM.CLOSED. +Per CM-23-003, CM-23-004, SYNC-02-002. +""" + +import pytest +from py_trees.common import Status + +from test.core.behaviors.sync.nodes.conftest import ( + CASE_ID, + OWNER_ACTOR_ID, + PARTICIPANT_ACTOR_ID, + _make_event, + _to_persistable_entry, +) +from vultron.core.behaviors.sync.nodes.close_case_effect import ( + ApplyCloseCaseFromLedgerNode, +) +from vultron.core.models.case_ledger import HashChainLedgerRecord +from vultron.core.states.rm import RM +from vultron.wire.as2.vocab.objects.case_participant import as_CaseParticipant +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + as_VulnerabilityCase, +) + +DEPARTING_ACTOR_ID = "https://example.org/actors/vendor" +DEPARTING_PARTICIPANT_ID = f"{CASE_ID}/participants/vendor" + + +def _make_close_case_entry(actor_id: str = DEPARTING_ACTOR_ID): + return _to_persistable_entry( + HashChainLedgerRecord( + case_id=CASE_ID, + log_index=0, + object_id="https://example.org/activities/close-case", + event_type="close_case", + payload_snapshot={"actor": {"id": actor_id}}, + prev_log_hash="0" * 64, + ) + ) + + +@pytest.fixture +def case_with_participant(datalayer): + participant = as_CaseParticipant( + id_=DEPARTING_PARTICIPANT_ID, + attributed_to=DEPARTING_ACTOR_ID, + context=CASE_ID, + ) + case = as_VulnerabilityCase( + id_=CASE_ID, name="Test Case", attributed_to=OWNER_ACTOR_ID + ) + case.add_participant(participant) + datalayer.save(participant) + datalayer.save(case) + return case, participant + + +def test_apply_close_case_advances_actor_to_rm_closed( + bridge, datalayer, case_actor, case_with_participant +): + """Departing actor's latest participant status reaches RM.CLOSED.""" + assert case_with_participant is not None + entry = _make_close_case_entry(DEPARTING_ACTOR_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + result = bridge.execute_with_setup( + tree=ApplyCloseCaseFromLedgerNode(name="ApplyCloseCase"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + + assert result.status == Status.SUCCESS + updated_participant = datalayer.read(DEPARTING_PARTICIPANT_ID) + assert updated_participant is not None + rm_states = [ + ps.rm.state + for ps in updated_participant.participant_statuses + if hasattr(ps, "rm") + ] + assert RM.CLOSED in rm_states, ( + f"Expected RM.CLOSED in participant statuses after close_case;" + f" got {rm_states}" + ) + + +def test_apply_close_case_idempotent( + bridge, datalayer, case_actor, case_with_participant +): + """Applying close_case twice does not add duplicate RM.CLOSED statuses.""" + assert case_with_participant is not None + entry = _make_close_case_entry(DEPARTING_ACTOR_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + for _ in range(2): + result = bridge.execute_with_setup( + tree=ApplyCloseCaseFromLedgerNode(name="ApplyCloseCase"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + assert result.status == Status.SUCCESS + + updated_participant = datalayer.read(DEPARTING_PARTICIPANT_ID) + closed_count = sum( + 1 + for ps in updated_participant.participant_statuses + if hasattr(ps, "rm") and ps.rm.state == RM.CLOSED + ) + assert ( + closed_count == 1 + ), f"Expected exactly one RM.CLOSED status; got {closed_count}" + + +def test_apply_close_case_skips_missing_case(bridge, case_actor): + """Node returns SUCCESS when the case is not in the local DataLayer.""" + entry = _make_close_case_entry(DEPARTING_ACTOR_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + result = bridge.execute_with_setup( + tree=ApplyCloseCaseFromLedgerNode(name="ApplyCloseCase"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + + assert result.status == Status.SUCCESS + + +def test_apply_close_case_skips_unknown_actor( + bridge, case_actor, case_with_participant +): + """Node returns SUCCESS when actor is not in actor_participant_index.""" + assert case_with_participant is not None + unknown_actor_id = "https://example.org/actors/unknown" + entry = _make_close_case_entry(unknown_actor_id) + event = _make_event(entry, actor_id=case_actor.id_) + + result = bridge.execute_with_setup( + tree=ApplyCloseCaseFromLedgerNode(name="ApplyCloseCase"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + + assert result.status == Status.SUCCESS diff --git a/test/core/behaviors/sync/nodes/test_invite_accept_effect.py b/test/core/behaviors/sync/nodes/test_invite_accept_effect.py new file mode 100644 index 000000000..bd8b40f18 --- /dev/null +++ b/test/core/behaviors/sync/nodes/test_invite_accept_effect.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +"""Tests for ApplyInviteAcceptFromLedgerNode. + +Covers accept_invite_actor_to_case ledger event application. +Per SYNC-02-002, ADR-0022, DEMOMA-07-003. +""" + +import pytest +from py_trees.common import Status + +from test.core.behaviors.sync.nodes.conftest import ( + CASE_ID, + OWNER_ACTOR_ID, + PARTICIPANT_ACTOR_ID, + _make_event, + _to_persistable_entry, +) +from vultron.core.behaviors.sync.nodes.invite_accept_effect import ( + ApplyInviteAcceptFromLedgerNode, +) +from vultron.core.models.case_ledger import HashChainLedgerRecord +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + as_VulnerabilityCase, +) + +INVITEE_ACTOR_ID = "https://example.org/actors/vendor2" + + +def _make_invite_accept_entry(invitee_id: str = INVITEE_ACTOR_ID): + return _to_persistable_entry( + HashChainLedgerRecord( + case_id=CASE_ID, + log_index=0, + object_id="https://example.org/activities/accept-invite", + event_type="accept_invite_actor_to_case", + payload_snapshot={"actor": {"id": invitee_id}}, + prev_log_hash="0" * 64, + ) + ) + + +@pytest.fixture +def case_with_actor(datalayer): + case = as_VulnerabilityCase( + id_=CASE_ID, name="Test Case", attributed_to=OWNER_ACTOR_ID + ) + datalayer.save(case) + return case + + +def test_apply_invite_accept_adds_participant( + bridge, datalayer, case_actor, case_with_actor +): + """Invitee is added to case.actor_participant_index after invite-accept.""" + assert case_with_actor is not None + entry = _make_invite_accept_entry(INVITEE_ACTOR_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + result = bridge.execute_with_setup( + tree=ApplyInviteAcceptFromLedgerNode(name="ApplyInviteAccept"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + + assert result.status == Status.SUCCESS + updated = datalayer.read(CASE_ID) + assert updated is not None + assert INVITEE_ACTOR_ID in updated.actor_participant_index + + +def test_apply_invite_accept_idempotent( + bridge, datalayer, case_actor, case_with_actor +): + """Applying the same invite-accept twice does not duplicate participant.""" + assert case_with_actor is not None + entry = _make_invite_accept_entry(INVITEE_ACTOR_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + for _ in range(2): + result = bridge.execute_with_setup( + tree=ApplyInviteAcceptFromLedgerNode(name="ApplyInviteAccept"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + assert result.status == Status.SUCCESS + + updated = datalayer.read(CASE_ID) + actor_ids = list(updated.actor_participant_index.keys()) + assert actor_ids.count(INVITEE_ACTOR_ID) == 1 + + +def test_apply_invite_accept_skips_missing_case(bridge, case_actor): + """Node returns SUCCESS when the case is not in the local DataLayer.""" + entry = _make_invite_accept_entry(INVITEE_ACTOR_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + result = bridge.execute_with_setup( + tree=ApplyInviteAcceptFromLedgerNode(name="ApplyInviteAccept"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + + assert result.status == Status.SUCCESS diff --git a/test/core/behaviors/sync/nodes/test_note_effect.py b/test/core/behaviors/sync/nodes/test_note_effect.py new file mode 100644 index 000000000..d63591108 --- /dev/null +++ b/test/core/behaviors/sync/nodes/test_note_effect.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +"""Tests for ApplyNoteFromLedgerNode. + +Covers add_note_to_case ledger event application to the local case replica. +Per SYNC-02-002, ADR-0022. +""" + +import pytest +from py_trees.common import Status + +from test.core.behaviors.sync.nodes.conftest import ( + CASE_ID, + OWNER_ACTOR_ID, + PARTICIPANT_ACTOR_ID, + _make_event, + _to_persistable_entry, +) +from vultron.core.behaviors.sync.nodes.note_effect import ( + ApplyNoteFromLedgerNode, +) +from vultron.core.models.case_ledger import HashChainLedgerRecord +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + as_VulnerabilityCase, +) + +NOTE_ID = "https://example.org/notes/note-01" + + +def _make_note_entry(note_id: str = NOTE_ID): + return _to_persistable_entry( + HashChainLedgerRecord( + case_id=CASE_ID, + log_index=0, + object_id="https://example.org/activities/add-note", + event_type="add_note_to_case", + payload_snapshot={"object": {"id": note_id}}, + prev_log_hash="0" * 64, + ) + ) + + +@pytest.fixture +def case_with_notes(datalayer): + case = as_VulnerabilityCase( + id_=CASE_ID, name="Test Case", attributed_to=OWNER_ACTOR_ID + ) + datalayer.save(case) + return case + + +def test_apply_note_adds_to_case( + bridge, datalayer, case_actor, case_with_notes +): + """Note ID is appended to the case's notes list.""" + assert case_with_notes is not None + entry = _make_note_entry(NOTE_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + result = bridge.execute_with_setup( + tree=ApplyNoteFromLedgerNode(name="ApplyNote"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + + assert result.status == Status.SUCCESS + updated = datalayer.read(CASE_ID) + assert updated is not None + note_ids = [ + n if isinstance(n, str) else getattr(n, "id_", str(n)) + for n in updated.notes + ] + assert NOTE_ID in note_ids + + +def test_apply_note_idempotent(bridge, datalayer, case_actor, case_with_notes): + """Applying the same note twice does not duplicate it.""" + assert case_with_notes is not None + entry = _make_note_entry(NOTE_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + for _ in range(2): + result = bridge.execute_with_setup( + tree=ApplyNoteFromLedgerNode(name="ApplyNote"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + assert result.status == Status.SUCCESS + + updated = datalayer.read(CASE_ID) + note_ids = [ + n if isinstance(n, str) else getattr(n, "id_", str(n)) + for n in updated.notes + ] + assert note_ids.count(NOTE_ID) == 1 + + +def test_apply_note_skips_missing_case(bridge, case_actor): + """Node returns SUCCESS when the case is not in the local DataLayer.""" + entry = _make_note_entry(NOTE_ID) + event = _make_event(entry, actor_id=case_actor.id_) + + result = bridge.execute_with_setup( + tree=ApplyNoteFromLedgerNode(name="ApplyNote"), + actor_id=PARTICIPANT_ACTOR_ID, + activity=event, + ) + + assert result.status == Status.SUCCESS diff --git a/test/core/behaviors/sync/nodes/test_participant_status_effect.py b/test/core/behaviors/sync/nodes/test_participant_status_effect.py index 37c1f53c9..41b9fc2d6 100644 --- a/test/core/behaviors/sync/nodes/test_participant_status_effect.py +++ b/test/core/behaviors/sync/nodes/test_participant_status_effect.py @@ -25,19 +25,16 @@ _make_event, _to_persistable_entry, ) -from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer -from vultron.core.behaviors.bridge import BTBridge from vultron.core.behaviors.sync.nodes.participant_status_effect import ( ApplyParticipantStatusFromLedgerNode, ) -from vultron.core.models.case_actor import VultronCaseActor from vultron.core.models.case_ledger import ( compute_genesis_hash, HashChainLedgerRecord, ) +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.models.participant_status import ParticipantStatus from vultron.wire.as2.vocab.objects.case_participant import as_CaseParticipant _FIXED_CREATED_AT = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) @@ -66,11 +63,6 @@ def _make_participant_status_snapshot( vfd_state: str = "VFd", rm_state: str = "ACCEPTED", ) -> dict: - """Return a payload_snapshot dict as produced by build_activity_payload_snapshot. - - Uses camelCase keys (wire/alias format) matching how the Case Actor builds - the snapshot from an Add(ParticipantStatus, as_CaseParticipant) activity. - """ return { "object": { "id": status_id, @@ -91,7 +83,6 @@ def _make_status_entry( vfd_state: str = "VFd", rm_state: str = "ACCEPTED", ): - """Return a VultronCaseLedgerEntry for an add_participant_status_to_participant event.""" snapshot = _make_participant_status_snapshot( status_id=status_id, participant_id=participant_id, @@ -110,27 +101,6 @@ def _make_status_entry( ) -@pytest.fixture -def datalayer(): - return SqliteDataLayer("sqlite:///:memory:") - - -@pytest.fixture -def bridge(datalayer): - return BTBridge(datalayer=datalayer) - - -@pytest.fixture -def case_actor(datalayer): - actor = VultronCaseActor( - name="Case Actor", - attributed_to=OWNER_ACTOR_ID, - context=CASE_ID, - ) - datalayer.create(actor) - return actor - - @pytest.fixture def participant(datalayer): p = _make_participant() @@ -147,15 +117,9 @@ def test_apply_participant_status_roundtrip_preserves_vfd_state( was serialized with default values (vfd_state='vfd') rather than actual values. After the fix the saved participant must have the correct vfd_state from the ledger entry payload snapshot. - - as_CaseParticipant always auto-creates one default ParticipantStatus - (RM.START, CS_vfd.vfd) on construction. After applying the ledger entry, - the participant has the initial default PLUS the new status. The - regression manifests as the new status carrying default vfd/rm values - instead of the values from the snapshot. """ status_id = f"urn:uuid:{uuid.uuid4()}" - initial_count = len(participant.participant_statuses) # always ≥ 1 + initial_count = len(participant.participant_statuses) entry = _make_status_entry( status_id=status_id, @@ -176,25 +140,12 @@ def test_apply_participant_status_roundtrip_preserves_vfd_state( assert result.status == Status.SUCCESS updated = cast(as_CaseParticipant, datalayer.read(participant.id_)) - assert ( - updated is not None - ), "Participant must still be readable after status update" - assert len(updated.participant_statuses) == initial_count + 1, ( - f"Participant must have exactly one new status appended" - f" (expected {initial_count + 1}, got {len(updated.participant_statuses)})" - ) + assert updated is not None + assert len(updated.participant_statuses) == initial_count + 1 - # The last (newest) status in the list must carry the values from the - # ledger snapshot, not Pydantic serialization defaults. new_status = cast(ParticipantStatus, updated.participant_statuses[-1]) - assert new_status.vfd.state == CS_vfd.VFd, ( - f"vfd.state must be VFd, got {new_status.vfd.state!r} — " - "likely caused by CORE ParticipantStatus serialization mismatch " - "when appended to participant_statuses" - ) - assert ( - new_status.rm.state == RM.ACCEPTED - ), f"rm.state must be ACCEPTED, got {new_status.rm.state!r}" + assert new_status.vfd.state == CS_vfd.VFd + assert new_status.rm.state == RM.ACCEPTED def test_apply_participant_status_idempotent( @@ -221,13 +172,11 @@ def test_apply_participant_status_idempotent( updated = cast(as_CaseParticipant, datalayer.read(participant.id_)) assert updated is not None - assert ( - len(updated.participant_statuses) == initial_count + 1 - ), "Idempotent apply must not duplicate status entries" + assert len(updated.participant_statuses) == initial_count + 1 def test_apply_participant_status_skips_missing_participant( - bridge, datalayer, case_actor + bridge, case_actor ): """Node returns SUCCESS without error when participant not found locally.""" status_id = f"urn:uuid:{uuid.uuid4()}" diff --git a/vultron/core/behaviors/status/nodes/append/__init__.py b/vultron/core/behaviors/status/nodes/append/__init__.py new file mode 100644 index 000000000..82745519a --- /dev/null +++ b/vultron/core/behaviors/status/nodes/append/__init__.py @@ -0,0 +1,49 @@ +#!/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 + +"""Append-participant-status leaf nodes subpackage. + +Re-exports all public node classes from the two domain-specific submodules so +that existing import paths (``from vultron.core.behaviors.status.nodes.append +import ...``) continue to work without modification. + +Submodules: +- ``conditions``: Idempotency guards and RM-transition validation nodes +- ``actions``: DataLayer-mutating load, resolve, and append action nodes +""" + +from vultron.core.behaviors.status.nodes.append.actions import ( + AppendStatusAndSaveParticipantNode, + LoadParticipantNode, + ResolveAndPersistStatusObjectNode, +) +from vultron.core.behaviors.status.nodes.append.conditions import ( + CheckParticipantRMNotClosedNode, + CheckStatusNotAlreadyAppendedNode, + SkipIfIdempotentNode, + ValidateRMTransitionNode, +) + +__all__ = [ + # conditions + "SkipIfIdempotentNode", + "CheckStatusNotAlreadyAppendedNode", + "ValidateRMTransitionNode", + "CheckParticipantRMNotClosedNode", + # actions + "LoadParticipantNode", + "ResolveAndPersistStatusObjectNode", + "AppendStatusAndSaveParticipantNode", +] diff --git a/vultron/core/behaviors/status/nodes/append.py b/vultron/core/behaviors/status/nodes/append/actions.py similarity index 65% rename from vultron/core/behaviors/status/nodes/append.py rename to vultron/core/behaviors/status/nodes/append/actions.py index 3d7c71ed5..c2c9b6a73 100644 --- a/vultron/core/behaviors/status/nodes/append.py +++ b/vultron/core/behaviors/status/nodes/append/actions.py @@ -13,21 +13,24 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -"""Append-participant-status leaf nodes for DEMOMA-07-003 step 2. +"""Action nodes for the append-participant-status workflow. -Contains the leaf nodes that implement the append sequence: check idempotency, -load participant, resolve status object, and append + save. The RM-transition -guards that also participate in that sequence live in -:mod:`vultron.core.behaviors.status.nodes.rm_validation` (BTND-07-004). +Contains the three DataLayer-mutating action nodes: + +- :class:`LoadParticipantNode` — load the CaseParticipant from DataLayer to + blackboard. +- :class:`ResolveAndPersistStatusObjectNode` — resolve (and optionally persist) + the ParticipantStatus object. +- :class:`AppendStatusAndSaveParticipantNode` — append the resolved status to + the participant and persist. """ -import logging from typing import Any, cast import py_trees from py_trees.common import Status -from vultron.core.behaviors.helpers import DataLayerAction, DataLayerCondition +from vultron.core.behaviors.helpers import DataLayerAction from vultron.core.behaviors.status.nodes.dimension_filter import ( BB_DIMENSION_FILTER, resolve_dimension_filter, @@ -35,58 +38,6 @@ from vultron.core.models.case_participant import CaseParticipant from vultron.core.models.participant_status import ParticipantStatus from vultron.core.models.protocols import PersistableModel -from vultron.core.models._helpers import _as_id - -logger = logging.getLogger(__name__) - - -class SkipIfIdempotentNode(py_trees.behaviour.Behaviour): - """Idempotency guard for the append-participant-status Selector. - - Returns SUCCESS when *status_id* is already present in the participant's - status list — causing the parent Selector to short-circuit and skip the - append subtree. Returns FAILURE when the status is not yet appended, - allowing the parent Selector to continue to the append subtree. - - This is the inverse of :class:`CheckStatusNotAlreadyAppendedNode`: that - node is used to halt a Sequence on duplicate; this node is used to skip - an append Selector on duplicate. - - Per DEMOMA-07-003 step 2 idempotency requirement. - """ - - def __init__( - self, - status_id: str, - participant_id: str, - name: str | None = None, - ): - super().__init__(name=name or self.__class__.__name__) - self.status_id = status_id - self.participant_id = participant_id - - def setup(self, **kwargs: Any) -> None: - self.blackboard = py_trees.blackboard.Client(name=self.name) - self.blackboard.register_key( - key="append_status_participant", - access=py_trees.common.Access.READ, - ) - - def update(self) -> Status: - participant = self.blackboard.get("append_status_participant") - if participant is None: - return Status.FAILURE - - existing_ids = [_as_id(s) for s in participant.participant_statuses] - if self.status_id in existing_ids: - logging.getLogger(self.__class__.__module__).info( - "SkipIfIdempotentNode: status '%s' already on participant" - " '%s' — idempotent, skipping (SUCCESS)", - self.status_id, - self.participant_id, - ) - return Status.SUCCESS - return Status.FAILURE class LoadParticipantNode(DataLayerAction): @@ -135,66 +86,14 @@ def update(self) -> Status: return Status.SUCCESS -class CheckStatusNotAlreadyAppendedNode(DataLayerCondition): - """Check idempotency: is the status already appended to the participant? - - Returns SUCCESS if the status is NOT already on the participant - (i.e., it's safe to append). Returns SUCCESS if the participant has no - statuses yet. - - Returns FAILURE if the status ID already exists in the participant's - status list, indicating the append would be redundant. - """ - - def __init__( - self, status_id: str, participant_id: str, name: str | None = None - ): - super().__init__(name=name or self.__class__.__name__) - self.status_id = status_id - self.participant_id = participant_id - - def setup(self, **kwargs: Any) -> None: - super().setup(**kwargs) - self.blackboard.register_key( - key="append_status_participant", - access=py_trees.common.Access.READ, - ) - - def update(self) -> Status: - participant = self.blackboard.get("append_status_participant") - if participant is None: - self.feedback_message = "Participant not on blackboard" - self.logger.warning( - "CheckStatusNotAlreadyAppendedNode: %s", - self.feedback_message, - ) - return Status.FAILURE - - existing_ids = [_as_id(s) for s in participant.participant_statuses] - if self.status_id in existing_ids: - self.logger.info( - "CheckStatusNotAlreadyAppendedNode: status '%s' already" - " on participant '%s' — idempotent, skipping", - self.status_id, - self.participant_id, - ) - return Status.FAILURE - - self.logger.debug( - "CheckStatusNotAlreadyAppendedNode: status '%s' not yet appended", - self.status_id, - ) - return Status.SUCCESS - - class ResolveAndPersistStatusObjectNode(DataLayerAction): """Resolve the status object by ID, persisting fallback if needed. When :class:`~vultron.core.behaviors.status.nodes.dimension_filter.FilterParticipantStatusDimensionsNode` has partially accepted the inbound status, the *filtered* status (refused dimensions carried forward) is persisted at ``status_id`` and used in place - of the raw assertion, so that the appended record, the ledger ``object`` - reference and the Seam 2 emit all describe the accepted portion (RSH-05). + of the raw assertion, so that the appended record describes the accepted + portion (RSH-05). Otherwise tries the DataLayer first; if not found, uses ``status_obj_fallback``, saves it, then re-reads the canonical record. diff --git a/vultron/core/behaviors/status/nodes/append/conditions.py b/vultron/core/behaviors/status/nodes/append/conditions.py new file mode 100644 index 000000000..6b90d1f8c --- /dev/null +++ b/vultron/core/behaviors/status/nodes/append/conditions.py @@ -0,0 +1,156 @@ +#!/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 + +"""Condition (guard) nodes for the append-participant-status workflow. + +Contains the two idempotency-guard nodes used directly in the append sequence: + +- :class:`SkipIfIdempotentNode` — Selector-level skip when status is already + present. +- :class:`CheckStatusNotAlreadyAppendedNode` — Sequence-level guard: halt if + already appended. + +RM-transition guards live in +:mod:`vultron.core.behaviors.status.nodes.rm_validation` (BTND-07-004) and are +re-exported here for backward-compatibility. +""" + +import logging +from typing import Any + +import py_trees +from py_trees.common import Status + +from vultron.core.behaviors.helpers import DataLayerCondition +from vultron.core.models._helpers import _as_id +from vultron.core.behaviors.status.nodes.rm_validation import ( + CheckParticipantRMNotClosedNode, + ValidateRMTransitionNode, +) + +logger = logging.getLogger(__name__) + +__all__ = [ + "SkipIfIdempotentNode", + "CheckStatusNotAlreadyAppendedNode", + "ValidateRMTransitionNode", + "CheckParticipantRMNotClosedNode", +] + + +def _has_status_in_participant(participant: Any, status_id: str) -> bool: + """Return True when *status_id* is already in the participant's status list.""" + existing_ids = [_as_id(s) for s in participant.participant_statuses] + return status_id in existing_ids + + +class SkipIfIdempotentNode(py_trees.behaviour.Behaviour): + """Idempotency guard for the append-participant-status Selector. + + Returns SUCCESS when *status_id* is already present in the participant's + status list — causing the parent Selector to short-circuit and skip the + append subtree. Returns FAILURE when the status is not yet appended, + allowing the parent Selector to continue to the append subtree. + + This is the inverse of :class:`CheckStatusNotAlreadyAppendedNode`: that + node is used to halt a Sequence on duplicate; this node is used to skip + an append Selector on duplicate. + + Per DEMOMA-07-003 step 2 idempotency requirement. + """ + + def __init__( + self, + status_id: str, + participant_id: str, + name: str | None = None, + ): + super().__init__(name=name or self.__class__.__name__) + self.status_id = status_id + self.participant_id = participant_id + + def setup(self, **kwargs: Any) -> None: + super().setup(**kwargs) + self.blackboard = py_trees.blackboard.Client(name=self.name) + self.blackboard.register_key( + key="append_status_participant", + access=py_trees.common.Access.READ, + ) + + def update(self) -> Status: + participant = self.blackboard.get("append_status_participant") + if participant is None: + return Status.FAILURE + + if _has_status_in_participant(participant, self.status_id): + logging.getLogger(self.__class__.__module__).info( + "SkipIfIdempotentNode: status '%s' already on participant" + " '%s' — idempotent, skipping (SUCCESS)", + self.status_id, + self.participant_id, + ) + return Status.SUCCESS + return Status.FAILURE + + +class CheckStatusNotAlreadyAppendedNode(DataLayerCondition): + """Check idempotency: is the status already appended to the participant? + + Returns SUCCESS if the status is NOT already on the participant + (i.e., it's safe to append). Returns SUCCESS if the participant has no + statuses yet. + + Returns FAILURE if the status ID already exists in the participant's + status list, indicating the append would be redundant. + """ + + def __init__( + self, status_id: str, participant_id: str, name: str | None = None + ): + super().__init__(name=name or self.__class__.__name__) + self.status_id = status_id + self.participant_id = participant_id + + def setup(self, **kwargs: Any) -> None: + super().setup(**kwargs) + self.blackboard.register_key( + key="append_status_participant", + access=py_trees.common.Access.READ, + ) + + def update(self) -> Status: + participant = self.blackboard.get("append_status_participant") + if participant is None: + self.feedback_message = "Participant not on blackboard" + self.logger.warning( + "CheckStatusNotAlreadyAppendedNode: %s", + self.feedback_message, + ) + return Status.FAILURE + + if _has_status_in_participant(participant, self.status_id): + self.logger.info( + "CheckStatusNotAlreadyAppendedNode: status '%s' already" + " on participant '%s' — idempotent, skipping", + self.status_id, + self.participant_id, + ) + return Status.FAILURE + + self.logger.debug( + "CheckStatusNotAlreadyAppendedNode: status '%s' not yet appended", + self.status_id, + ) + return Status.SUCCESS diff --git a/vultron/core/behaviors/sync/nodes/__init__.py b/vultron/core/behaviors/sync/nodes/__init__.py index fc5d17b9d..706a76d5b 100644 --- a/vultron/core/behaviors/sync/nodes/__init__.py +++ b/vultron/core/behaviors/sync/nodes/__init__.py @@ -65,9 +65,13 @@ PersistReceivedLogEntryNode, SendRejectLogEntryNode, ) -from vultron.core.behaviors.sync.nodes.effects import ( +from vultron.core.behaviors.sync.nodes.close_case_effect import ( ApplyCloseCaseFromLedgerNode, +) +from vultron.core.behaviors.sync.nodes.invite_accept_effect import ( ApplyInviteAcceptFromLedgerNode, +) +from vultron.core.behaviors.sync.nodes.note_effect import ( ApplyNoteFromLedgerNode, ) from vultron.core.behaviors.sync.nodes.participant_status_effect import ( diff --git a/vultron/core/behaviors/sync/nodes/_helpers.py b/vultron/core/behaviors/sync/nodes/_helpers.py new file mode 100644 index 000000000..d18f276f9 --- /dev/null +++ b/vultron/core/behaviors/sync/nodes/_helpers.py @@ -0,0 +1,75 @@ +#!/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 + +"""Shared helpers for SYNC log-replication effect nodes. + +Provides the ``_extract_id_from_field`` utility and the +``_LedgerEffectNode`` base class used by all per-event-type effect nodes. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import py_trees +from py_trees.common import Status + +from vultron.core.behaviors.helpers import DataLayerAction + +logger = logging.getLogger(__name__) + + +def _extract_id_from_field(value: Any) -> str | None: + """Return the string ID from an AS2 object field. + + Handles None, bare string, inline dict (``{"id": ...}`` or ``{"id_": ...}`` + form), and object instances with ``id_`` or ``id`` attributes. + """ + if value is None: + return None + if isinstance(value, str): + return value or None + if isinstance(value, dict): + return value.get("id") or value.get("id_") or None + return getattr(value, "id_", None) or getattr(value, "id", None) or None + + +class _LedgerEffectNode(DataLayerAction): + """Base class for Announce(CaseLedgerEntry) received-side effect nodes. + + Registers the ``activity`` blackboard key in ``setup()`` and exposes + ``_get_entry()`` to retrieve the log entry from the blackboard without + repeating the import and call in every subclass. + + Subclasses override only ``update()`` with their specific side-effect logic. + """ + + def setup(self, **kwargs: Any) -> None: + super().setup(**kwargs) + self.blackboard.register_key( + key="activity", access=py_trees.common.Access.READ + ) + + def _get_entry(self): # type: ignore[return] + """Return the HashChainLedgerRecord from the blackboard activity.""" + from vultron.core.behaviors.sync.nodes.conditions import ( + _require_log_entry, + ) + + return _require_log_entry(self.blackboard.activity, self.name) + + def update(self) -> Status: # pragma: no cover + raise NotImplementedError diff --git a/vultron/core/behaviors/sync/nodes/close_case_effect.py b/vultron/core/behaviors/sync/nodes/close_case_effect.py new file mode 100644 index 000000000..59dc6234b --- /dev/null +++ b/vultron/core/behaviors/sync/nodes/close_case_effect.py @@ -0,0 +1,148 @@ +#!/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 + +"""Ledger effect node for ``close_case`` events. + +Per specs/case-management.yaml CM-23-003, CM-23-004, +specs/sync-ledger-replication.yaml SYNC-02-002, and ADR-0050. +""" + +from __future__ import annotations + +import logging + +from py_trees.common import Status + +from vultron.core.behaviors.sync.nodes._helpers import ( + _LedgerEffectNode, + _extract_id_from_field, +) +from vultron.core.models.case import VulnerabilityCase +from vultron.core.models.case_participant import CaseParticipant +from vultron.core.models.participant_status import participant_status_rm_state + +logger = logging.getLogger(__name__) + + +class ApplyCloseCaseFromLedgerNode(_LedgerEffectNode): + """Apply a ``close_case`` ledger entry to the local case replica. + + When a non-CaseActor participant receives ``Announce(CaseLedgerEntry)`` + and the entry's ``event_type`` is ``close_case``, this node extracts the + departing actor ID from ``payload_snapshot["actor"]`` and advances that + actor's :class:`~vultron.core.models.participant_status.ParticipantStatus` + to ``RM.CLOSED`` on the local DataLayer replica. + + This is the fan-out counterpart of the CaseActor's ``receive_close_case_tree`` + effect: both paths MUST produce the same end state on every replica + (CM-23-003, CM-23-004, ADR-0050). + + Lenient on missing data: if the case replica is absent, the departing actor + ID is not extractable, or the participant record is missing, the node + returns SUCCESS to avoid blocking the ``Announce`` processing flow. + + Per specs/case-management.yaml CM-23-003, CM-23-004, + specs/sync-ledger-replication.yaml SYNC-02-002. + """ + + def update(self) -> Status: + if (f := self._require_datalayer()) is not None: + return f + assert self.datalayer is not None + + from vultron.core.behaviors.case.nodes.participant.status import ( + CreateParticipantStatusNode, + ) + from vultron.core.states.rm import RM + + entry = self._get_entry() + snapshot = entry.payload_snapshot + case_id = entry.case_id + + departing_actor_id = _extract_id_from_field(snapshot.get("actor")) + if not departing_actor_id or not case_id: + self.logger.debug( + "%s: payload_snapshot missing 'actor' id or case_id" + " — skipping close-case apply (non-fatal)", + self.name, + ) + return Status.SUCCESS + + case = self.datalayer.read(case_id) + if not isinstance(case, VulnerabilityCase): + self.logger.debug( + "%s: case '%s' not found in local DataLayer" + " — skipping (non-fatal, partial case view)", + self.name, + case_id, + ) + return Status.SUCCESS + + if departing_actor_id not in case.actor_participant_index: + self.logger.debug( + "%s: departing actor '%s' not in actor_participant_index" + " for case '%s' — skipping (non-fatal)", + self.name, + departing_actor_id, + case_id, + ) + return Status.SUCCESS + + # Idempotency: skip if already at RM.CLOSED + participant_id = case.actor_participant_index[departing_actor_id] + participant = self.datalayer.read(participant_id) + if isinstance(participant, CaseParticipant): + for ps in participant.participant_statuses: + if participant_status_rm_state(ps) == RM.CLOSED: + self.logger.debug( + "%s: departing actor '%s' already at RM.CLOSED — no-op", + self.name, + departing_actor_id, + ) + return Status.SUCCESS + + # Advance the departing actor to RM.CLOSED using CreateParticipantStatusNode + # logic directly (avoids re-entering the BT machinery). + result_out: dict = {} + node = CreateParticipantStatusNode( + case_id=case_id, + actor_id=departing_actor_id, + rm_state=RM.CLOSED, + vfd_state=None, + pxa_state=None, + result_out=result_out, + name=f"{self.name}.CreateParticipantStatus", + ) + node.datalayer = self.datalayer + node.actor_id = departing_actor_id + result = node.update() + if result != Status.SUCCESS: + self.logger.warning( + "%s: failed to advance departing actor '%s' to RM.CLOSED" + " in case '%s'", + self.name, + departing_actor_id, + case_id, + ) + return Status.FAILURE + + self.logger.info( + "%s: applied ledger close-case for departing actor '%s'" + " in case '%s' (CM-23-003, SYNC-02-002)", + self.name, + departing_actor_id, + case_id, + ) + return Status.SUCCESS diff --git a/vultron/core/behaviors/sync/nodes/effects.py b/vultron/core/behaviors/sync/nodes/effects.py index 37eeb7ba1..a1a180c40 100644 --- a/vultron/core/behaviors/sync/nodes/effects.py +++ b/vultron/core/behaviors/sync/nodes/effects.py @@ -13,52 +13,28 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -"""Side-effect action nodes for Announce(CaseLedgerEntry) received processing. +"""Shared helpers for Announce(CaseLedgerEntry) ledger-apply modules. -Provides action nodes that apply protocol-significant side effects when a -non-Case-Actor participant processes a ledger entry of a specific event type. +Provides :func:`_extract_id_from_field`, used by the per-effect modules listed +below. Effect classes live in their own modules (BTND-07-004): -Currently implemented effects: - -- :class:`ApplyNoteFromLedgerNode`: applies an ``add_note_to_case`` event to - the local case replica by attaching the note ID to ``notes``. -- :class:`ApplyInviteAcceptFromLedgerNode`: applies an - ``accept_invite_actor_to_case`` event to the local case replica by creating - a stub ``CaseParticipant`` for the new invitee and calling ``add_participant``. -- :class:`ApplyCloseCaseFromLedgerNode`: applies a ``close_case`` event to - the local case replica by advancing the departing actor's - :class:`~vultron.core.models.participant_status.ParticipantStatus` to - ``RM.CLOSED`` (CM-23-003, ADR-0050). - -Effects that carry enough logic to warrant their own module live beside this -one and import :func:`_extract_id_from_field` from here: -:mod:`~vultron.core.behaviors.sync.nodes.participant_status_effect`, -:mod:`~vultron.core.behaviors.sync.nodes.ownership_effects` and -:mod:`~vultron.core.behaviors.sync.nodes.offer_report_effect` (BTND-07-004). +- :mod:`~vultron.core.behaviors.sync.nodes.note_effect` — + :class:`~vultron.core.behaviors.sync.nodes.note_effect.ApplyNoteFromLedgerNode` +- :mod:`~vultron.core.behaviors.sync.nodes.invite_accept_effect` — + :class:`~vultron.core.behaviors.sync.nodes.invite_accept_effect.ApplyInviteAcceptFromLedgerNode` +- :mod:`~vultron.core.behaviors.sync.nodes.close_case_effect` — + :class:`~vultron.core.behaviors.sync.nodes.close_case_effect.ApplyCloseCaseFromLedgerNode` +- :mod:`~vultron.core.behaviors.sync.nodes.participant_status_effect` — + :class:`~vultron.core.behaviors.sync.nodes.participant_status_effect.ApplyParticipantStatusFromLedgerNode` Per specs/multi-actor-demo.yaml DEMOMA-07-003 step 3, -specs/case-management.yaml CM-23-003, and specs/sync-ledger-replication.yaml SYNC-02-002. """ from __future__ import annotations -import logging from typing import Any -import py_trees -from py_trees.common import Status - -from vultron.core.behaviors.helpers import DataLayerAction -from vultron.core.models._helpers import _as_id -from vultron.core.models.case import VulnerabilityCase -from vultron.core.models.case_participant import CaseParticipant -from vultron.core.models.participant_status import ( - participant_status_rm_state, -) - -logger = logging.getLogger(__name__) - def _extract_id_from_field(value: Any) -> str | None: """Return the string ID from an AS2 object field. @@ -73,289 +49,3 @@ def _extract_id_from_field(value: Any) -> str | None: if isinstance(value, dict): return value.get("id") or value.get("id_") or None return getattr(value, "id_", None) or getattr(value, "id", None) or None - - -class ApplyNoteFromLedgerNode(DataLayerAction): - """Apply an ``add_note_to_case`` ledger entry to the local case replica. - - When a non-CaseActor participant receives ``Announce(CaseLedgerEntry)`` - and the entry's ``event_type`` is ``add_note_to_case``, this node - extracts the note ID from ``payload_snapshot["object"]`` and appends it - to the local case replica's ``notes`` list (idempotent). - - This is the canonical mechanism by which non-CaseActor participants - learn about note additions — they must NOT update ``notes`` directly from - ``Add(Note, Case)`` messages; only the CaseActor does that (ADR-0022, - SYNC-02-002). - - Lenient on missing data: if the case replica is absent, the note ID is - not present in the snapshot, or the snapshot is malformed, the node - returns SUCCESS to avoid blocking the ``Announce`` processing flow. - """ - - def setup(self, **kwargs: Any) -> None: - super().setup(**kwargs) - self.blackboard.register_key( - key="activity", access=py_trees.common.Access.READ - ) - - def update(self) -> Status: - if (f := self._require_datalayer()) is not None: - return f - assert self.datalayer is not None - from vultron.core.behaviors.sync.nodes.conditions import ( - _require_log_entry, - ) - - entry = _require_log_entry(self.blackboard.activity, self.name) - snapshot = entry.payload_snapshot - - note_id = _extract_id_from_field(snapshot.get("object")) - case_id = entry.case_id - - if not note_id or not case_id: - self.logger.debug( - "%s: payload_snapshot missing 'object' id or case_id" - " — skipping note apply (non-fatal)", - self.name, - ) - return Status.SUCCESS - - case = self.datalayer.read(case_id) - if not isinstance(case, VulnerabilityCase): - self.logger.debug( - "%s: case '%s' not found in local DataLayer" - " — skipping (non-fatal, partial case view)", - self.name, - case_id, - ) - return Status.SUCCESS - - existing_ids = [_as_id(n) for n in case.notes] - if note_id in existing_ids: - self.logger.debug( - "%s: note '%s' already in case '%s' — idempotent no-op", - self.name, - note_id, - case_id, - ) - return Status.SUCCESS - - case.notes.append(note_id) - self.datalayer.save(case) - self.logger.info( - "%s: applied ledger note attachment '%s' to case '%s' (SYNC-02-002)", - self.name, - note_id, - case_id, - ) - return Status.SUCCESS - - -class ApplyInviteAcceptFromLedgerNode(DataLayerAction): - """Apply an ``accept_invite_actor_to_case`` ledger entry to the local case replica. - - When a non-CaseActor participant receives ``Announce(CaseLedgerEntry)`` - and the entry's ``event_type`` is ``accept_invite_actor_to_case``, this - node extracts the invitee actor ID from ``payload_snapshot["actor"]``, - creates a stub ``CaseParticipant``, and calls ``case.add_participant()`` - to add the new participant to the local case replica (idempotent). - - This is the mechanism by which existing participants (e.g. the Finder) - learn that a new actor (e.g. Vendor2) has joined the case — they MUST NOT - update ``case_participants`` directly from ``Accept(Invite)`` messages; - only the CaseActor does that. All other participants learn via this ledger - entry effect (ADR-0022, SYNC-02-002, DEMOMA-07-003). - - Lenient on missing data: if the case replica is absent, the invitee ID - cannot be extracted, or the participant is already present, the node - returns SUCCESS to avoid blocking the ``Announce`` processing flow. - """ - - def setup(self, **kwargs: Any) -> None: - super().setup(**kwargs) - self.blackboard.register_key( - key="activity", access=py_trees.common.Access.READ - ) - - def update(self) -> Status: - if (f := self._require_datalayer()) is not None: - return f - assert self.datalayer is not None - - from vultron.core.behaviors.sync.nodes.conditions import ( - _require_log_entry, - ) - - entry = _require_log_entry(self.blackboard.activity, self.name) - snapshot = entry.payload_snapshot - case_id = entry.case_id - - invitee_id = _extract_id_from_field(snapshot.get("actor")) - if not invitee_id or not case_id: - self.logger.debug( - "%s: payload_snapshot missing 'actor' id or case_id" - " — skipping invite-accept apply (non-fatal)", - self.name, - ) - return Status.SUCCESS - - case = self.datalayer.read(case_id) - if not isinstance(case, VulnerabilityCase): - self.logger.debug( - "%s: case '%s' not found in local DataLayer" - " — skipping (non-fatal, partial case view)", - self.name, - case_id, - ) - return Status.SUCCESS - - if invitee_id in case.actor_participant_index: - self.logger.debug( - "%s: invitee '%s' already in actor_participant_index" - " for case '%s' — idempotent no-op", - self.name, - invitee_id, - case_id, - ) - return Status.SUCCESS - - participant = CaseParticipant( - id_=f"{case_id}/participants/{invitee_id.rstrip('/').rsplit('/', 1)[-1]}", - attributed_to=invitee_id, - context=case_id, - ) - if self.datalayer.read(participant.id_) is None: - self.datalayer.create(participant) - - case.add_participant(participant) - self.datalayer.save(case) - self.logger.info( - "%s: applied ledger invite-accept for invitee '%s' to case '%s'" - " (SYNC-02-002, DEMOMA-07-003)", - self.name, - invitee_id, - case_id, - ) - return Status.SUCCESS - - -class ApplyCloseCaseFromLedgerNode(DataLayerAction): - """Apply a ``close_case`` ledger entry to the local case replica. - - When a non-CaseActor participant receives ``Announce(CaseLedgerEntry)`` - and the entry's ``event_type`` is ``close_case``, this node extracts the - departing actor ID from ``payload_snapshot["actor"]`` and advances that - actor's :class:`~vultron.core.models.participant_status.ParticipantStatus` - to ``RM.CLOSED`` on the local DataLayer replica. - - This is the fan-out counterpart of the CaseActor's ``receive_close_case_tree`` - effect: both paths MUST produce the same end state on every replica - (CM-23-003, CM-23-004, ADR-0050). - - Lenient on missing data: if the case replica is absent, the departing actor - ID is not extractable, or the participant record is missing, the node - returns SUCCESS to avoid blocking the ``Announce`` processing flow. - - Per specs/case-management.yaml CM-23-003, CM-23-004, - specs/sync-ledger-replication.yaml SYNC-02-002. - """ - - def setup(self, **kwargs: Any) -> None: - super().setup(**kwargs) - self.blackboard.register_key( - key="activity", access=py_trees.common.Access.READ - ) - - def update(self) -> Status: - if (f := self._require_datalayer()) is not None: - return f - assert self.datalayer is not None - - from vultron.core.behaviors.sync.nodes.conditions import ( - _require_log_entry, - ) - from vultron.core.behaviors.case.nodes.participant.status import ( - CreateParticipantStatusNode, - ) - from vultron.core.states.rm import RM - - entry = _require_log_entry(self.blackboard.activity, self.name) - snapshot = entry.payload_snapshot - case_id = entry.case_id - - departing_actor_id = _extract_id_from_field(snapshot.get("actor")) - if not departing_actor_id or not case_id: - self.logger.debug( - "%s: payload_snapshot missing 'actor' id or case_id" - " — skipping close-case apply (non-fatal)", - self.name, - ) - return Status.SUCCESS - - case = self.datalayer.read(case_id) - if not isinstance(case, VulnerabilityCase): - self.logger.debug( - "%s: case '%s' not found in local DataLayer" - " — skipping (non-fatal, partial case view)", - self.name, - case_id, - ) - return Status.SUCCESS - - if departing_actor_id not in case.actor_participant_index: - self.logger.debug( - "%s: departing actor '%s' not in actor_participant_index" - " for case '%s' — skipping (non-fatal)", - self.name, - departing_actor_id, - case_id, - ) - return Status.SUCCESS - - # Idempotency: skip if already at RM.CLOSED - participant_id = case.actor_participant_index[departing_actor_id] - participant = self.datalayer.read(participant_id) - if isinstance(participant, CaseParticipant): - for ps in participant.participant_statuses: - if participant_status_rm_state(ps) == RM.CLOSED: - self.logger.debug( - "%s: departing actor '%s' already at RM.CLOSED — no-op", - self.name, - departing_actor_id, - ) - return Status.SUCCESS - - # Advance the departing actor to RM.CLOSED using CreateParticipantStatusNode - # logic directly (avoids re-entering the BT machinery). - result_out: dict = {} - node = CreateParticipantStatusNode( - case_id=case_id, - actor_id=departing_actor_id, - rm_state=RM.CLOSED, - vfd_state=None, - pxa_state=None, - result_out=result_out, - name=f"{self.name}.CreateParticipantStatus", - ) - node.datalayer = self.datalayer - node.actor_id = departing_actor_id - result = node.update() - if result != Status.SUCCESS: - self.logger.warning( - "%s: failed to advance departing actor '%s' to RM.CLOSED" - " in case '%s'", - self.name, - departing_actor_id, - case_id, - ) - return Status.FAILURE - - self.logger.info( - "%s: applied ledger close-case for departing actor '%s'" - " in case '%s' (CM-23-003, SYNC-02-002)", - self.name, - departing_actor_id, - case_id, - ) - return Status.SUCCESS diff --git a/vultron/core/behaviors/sync/nodes/invite_accept_effect.py b/vultron/core/behaviors/sync/nodes/invite_accept_effect.py new file mode 100644 index 000000000..94ef75206 --- /dev/null +++ b/vultron/core/behaviors/sync/nodes/invite_accept_effect.py @@ -0,0 +1,113 @@ +#!/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 + +"""Ledger effect node for ``accept_invite_actor_to_case`` events. + +Per specs/sync-ledger-replication.yaml SYNC-02-002, ADR-0022, +and specs/multi-actor-demo.yaml DEMOMA-07-003. +""" + +from __future__ import annotations + +import logging + +from py_trees.common import Status + +from vultron.core.behaviors.sync.nodes._helpers import ( + _LedgerEffectNode, + _extract_id_from_field, +) +from vultron.core.models.case import VulnerabilityCase +from vultron.core.models.case_participant import CaseParticipant + +logger = logging.getLogger(__name__) + + +class ApplyInviteAcceptFromLedgerNode(_LedgerEffectNode): + """Apply an ``accept_invite_actor_to_case`` ledger entry to the local case replica. + + When a non-CaseActor participant receives ``Announce(CaseLedgerEntry)`` + and the entry's ``event_type`` is ``accept_invite_actor_to_case``, this + node extracts the invitee actor ID from ``payload_snapshot["actor"]``, + creates a stub ``CaseParticipant``, and calls ``case.add_participant()`` + to add the new participant to the local case replica (idempotent). + + This is the mechanism by which existing participants (e.g. the Finder) + learn that a new actor (e.g. Vendor2) has joined the case — they MUST NOT + update ``case_participants`` directly from ``Accept(Invite)`` messages; + only the CaseActor does that. All other participants learn via this ledger + entry effect (ADR-0022, SYNC-02-002, DEMOMA-07-003). + + Lenient on missing data: if the case replica is absent, the invitee ID + cannot be extracted, or the participant is already present, the node + returns SUCCESS to avoid blocking the ``Announce`` processing flow. + """ + + def update(self) -> Status: + if (f := self._require_datalayer()) is not None: + return f + assert self.datalayer is not None + + entry = self._get_entry() + snapshot = entry.payload_snapshot + case_id = entry.case_id + + invitee_id = _extract_id_from_field(snapshot.get("actor")) + if not invitee_id or not case_id: + self.logger.debug( + "%s: payload_snapshot missing 'actor' id or case_id" + " — skipping invite-accept apply (non-fatal)", + self.name, + ) + return Status.SUCCESS + + case = self.datalayer.read(case_id) + if not isinstance(case, VulnerabilityCase): + self.logger.debug( + "%s: case '%s' not found in local DataLayer" + " — skipping (non-fatal, partial case view)", + self.name, + case_id, + ) + return Status.SUCCESS + + if invitee_id in case.actor_participant_index: + self.logger.debug( + "%s: invitee '%s' already in actor_participant_index" + " for case '%s' — idempotent no-op", + self.name, + invitee_id, + case_id, + ) + return Status.SUCCESS + + participant = CaseParticipant( + id_=f"{case_id}/participants/{invitee_id.rstrip('/').rsplit('/', 1)[-1]}", + attributed_to=invitee_id, + context=case_id, + ) + if self.datalayer.read(participant.id_) is None: + self.datalayer.create(participant) + + case.add_participant(participant) + self.datalayer.save(case) + self.logger.info( + "%s: applied ledger invite-accept for invitee '%s' to case '%s'" + " (SYNC-02-002, DEMOMA-07-003)", + self.name, + invitee_id, + case_id, + ) + return Status.SUCCESS diff --git a/vultron/core/behaviors/sync/nodes/note_effect.py b/vultron/core/behaviors/sync/nodes/note_effect.py new file mode 100644 index 000000000..f64ad8cd4 --- /dev/null +++ b/vultron/core/behaviors/sync/nodes/note_effect.py @@ -0,0 +1,102 @@ +#!/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 + +"""Ledger effect node for ``add_note_to_case`` events. + +Per specs/sync-ledger-replication.yaml SYNC-02-002 and ADR-0022. +""" + +from __future__ import annotations + +import logging + +from py_trees.common import Status + +from vultron.core.behaviors.sync.nodes._helpers import ( + _LedgerEffectNode, + _extract_id_from_field, +) +from vultron.core.models.case import VulnerabilityCase +from vultron.core.models._helpers import _as_id + +logger = logging.getLogger(__name__) + + +class ApplyNoteFromLedgerNode(_LedgerEffectNode): + """Apply an ``add_note_to_case`` ledger entry to the local case replica. + + When a non-CaseActor participant receives ``Announce(CaseLedgerEntry)`` + and the entry's ``event_type`` is ``add_note_to_case``, this node + extracts the note ID from ``payload_snapshot["object"]`` and appends it + to the local case replica's ``notes`` list (idempotent). + + This is the canonical mechanism by which non-CaseActor participants + learn about note additions — they must NOT update ``notes`` directly from + ``Add(Note, Case)`` messages; only the CaseActor does that (ADR-0022, + SYNC-02-002). + + Lenient on missing data: if the case replica is absent, the note ID is + not present in the snapshot, or the snapshot is malformed, the node + returns SUCCESS to avoid blocking the ``Announce`` processing flow. + """ + + def update(self) -> Status: + if (f := self._require_datalayer()) is not None: + return f + assert self.datalayer is not None + + entry = self._get_entry() + snapshot = entry.payload_snapshot + + note_id = _extract_id_from_field(snapshot.get("object")) + case_id = entry.case_id + + if not note_id or not case_id: + self.logger.debug( + "%s: payload_snapshot missing 'object' id or case_id" + " — skipping note apply (non-fatal)", + self.name, + ) + return Status.SUCCESS + + case = self.datalayer.read(case_id) + if not isinstance(case, VulnerabilityCase): + self.logger.debug( + "%s: case '%s' not found in local DataLayer" + " — skipping (non-fatal, partial case view)", + self.name, + case_id, + ) + return Status.SUCCESS + + existing_ids = [_as_id(n) for n in case.notes] + if note_id in existing_ids: + self.logger.debug( + "%s: note '%s' already in case '%s' — idempotent no-op", + self.name, + note_id, + case_id, + ) + return Status.SUCCESS + + case.notes.append(note_id) + self.datalayer.save(case) + self.logger.info( + "%s: applied ledger note attachment '%s' to case '%s' (SYNC-02-002)", + self.name, + note_id, + case_id, + ) + return Status.SUCCESS diff --git a/vultron/core/behaviors/sync/nodes/offer_report_effect.py b/vultron/core/behaviors/sync/nodes/offer_report_effect.py index 615477e31..047f16a54 100644 --- a/vultron/core/behaviors/sync/nodes/offer_report_effect.py +++ b/vultron/core/behaviors/sync/nodes/offer_report_effect.py @@ -30,7 +30,7 @@ from py_trees.common import Status from vultron.core.behaviors.helpers import DataLayerAction -from vultron.core.behaviors.sync.nodes.effects import _extract_id_from_field +from vultron.core.behaviors.sync.nodes._helpers import _extract_id_from_field class ApplyOfferReportFromLedgerNode(DataLayerAction): diff --git a/vultron/core/behaviors/sync/nodes/ownership_effects.py b/vultron/core/behaviors/sync/nodes/ownership_effects.py index 81569191e..d70f170e5 100644 --- a/vultron/core/behaviors/sync/nodes/ownership_effects.py +++ b/vultron/core/behaviors/sync/nodes/ownership_effects.py @@ -29,7 +29,7 @@ from py_trees.common import Status from vultron.core.behaviors.helpers import DataLayerAction -from vultron.core.behaviors.sync.nodes.effects import _extract_id_from_field +from vultron.core.behaviors.sync.nodes._helpers import _extract_id_from_field from vultron.core.models._helpers import _as_id from vultron.core.models.case import VulnerabilityCase diff --git a/vultron/core/use_cases/triggers/case/leave.py b/vultron/core/use_cases/triggers/case/leave.py index 495b717fe..32f5b8038 100644 --- a/vultron/core/use_cases/triggers/case/leave.py +++ b/vultron/core/use_cases/triggers/case/leave.py @@ -19,7 +19,7 @@ commits a ``close_case`` :class:`~vultron.core.models.case_ledger_entry .VultronCaseLedgerEntry` and broadcasts it; each replica then advances the departing actor's RM state to ``RM.CLOSED`` via -:class:`~vultron.core.behaviors.sync.nodes.effects.ApplyCloseCaseFromLedgerNode` +:class:`~vultron.core.behaviors.sync.nodes.close_case_effect.ApplyCloseCaseFromLedgerNode` (ADR-0050, CM-23-002/CM-23-003). Per specs/case-management.yaml DEMOMA-07-001, CM-23-002, CM-23-003.