Skip to content
24 changes: 24 additions & 0 deletions plan/BUILD_LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,30 @@ with a spec-linked `NotImplementedError`. A dedicated adapter-level unit test
prevents future placeholder edits from silently downgrading the fail-fast
signal into a no-op module.

### 2026-06-15 SYNC-789 — BTBridge must carry sync_port when BTs commit ledger entries

When a use-case's BT contains \`CommitCaseLedgerEntryNode\`, the \`BTBridge\`
that runs that BT **must** be constructed with \`sync_port=\` set.
\`CommitCaseLedgerEntryNode\` reads \`sync_port\` from the py_trees blackboard
and silently skips the \`Announce(CaseLedgerEntry)\` fan-out (SYNC-2) with
\`Status.SUCCESS\` when the key is absent — no error, no warning loud enough to
catch in unit tests.

**Pattern to follow**: Any semantic whose use case runs a BT containing
\`CommitCaseLedgerEntryNode\` must be placed in
\`_SYNC_AND_TRIGGER_PORT_SEMANTICS\` (if it also needs \`trigger_activity\`) or
\`_SYNC_PORT_SEMANTICS\` (if it only needs sync). The use-case \`**init**\`
must also accept \`sync_port\` and pass it to \`BTBridge(sync_port=sync_port)\`.

**How to diagnose**: Look for
\`"sync_port not injected; skipping fan-out for '...'"`(DEBUG level) in
container logs, or for replicas with zero \`CaseLedgerEntry\` records when
the authority has committed entries. This is never a timeout/race — the
fan-out simply does not run.

**Affected semantics fixed**: \`SUBMIT_REPORT\`, \`ENGAGE_CASE\`, \`DEFER_CASE\`
(all moved to \`_SYNC_AND_TRIGGER_PORT_SEMANTICS\` in PR #944).

### 2026-06-12 RENAME-934 — pytest mark registration must mirror class/file renames

When renaming a pytest mark (e.g., `case_log_invariants` → `case_ledger_invariants`),
Expand Down
48 changes: 48 additions & 0 deletions plan/history/2606/implementation/ISSUE-789.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
source: ISSUE-789
timestamp: '2026-06-12T20:54:38.924550+00:00'
title: Migrate case history write paths to CaseLedger commits
type: implementation
---

## Issue #789 — Migrate case history write paths to CaseActor-authorized canonical commits

Removed all `VulnerabilityCase.record_event()` dual-writes from BT nodes and
standalone use-case call sites, replacing them with canonical `CaseLedgerEntry`
commits via `CommitCaseLedgerEntryNode`, `CommitLogCascadeNode`, and
`commit_log_entry_trigger`.

### Changes

#### Phase A — Remove dual-write record_event from BT nodes

- `RecordCaseCreationEvents` subtree removed from `CreateCaseFlow`
- `RecordParticipantAddedEventNode` removed from `CreateCaseParticipantNode`;
`datalayer.save()` moved into `AttachParticipantToCaseNode`
- `RecordOwnerJoinedEventNode` removed from `CreateCaseOwnerParticipant`
- `record_event` removed from `SetEmbargoActiveNode` and
`RecordParticipantAcceptanceNode`

#### Phase B — Replace standalone record_event in use cases

- `AcceptInviteActorToCaseReceivedUseCase`: replaced `participant_joined` /
`embargo_accepted` calls with `commit_log_entry_trigger`
- `AddCaseParticipantToCaseReceivedUseCase`: removed `participant_added` call

#### Phase C — Fix event_type strings to match EXPECTED_EVENT_TYPES

- `accept_report`, `accept_embargo`, `add_note`, `propose_embargo`
- `notify_fix_ready` / `notify_fix_deployed` / `notify_published` /
`add_participant_status` via `_status_event_type()` in
`AddParticipantStatusToParticipantReceivedUseCase`
- `payloadSnapshot` enriched with `emConsentState` / `cvdRole` / `rmState`
for invariants 7 and 9

#### Phase D — Flip xfail markers to passing (invariants 1–5, 7, 9)

### Outcome

All 7 targeted invariants now pass without xfail.
3202 unit tests passed; all linters (black, flake8, mypy, pyright) clean.

PR: [#944](https://github.com/CERTCC/Vultron/pull/944)
77 changes: 6 additions & 71 deletions test/ci/test_case_ledger_invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,22 +221,10 @@ def _contiguous_fragments(entries: list[dict]) -> list[list[dict]]:
# Invariant 1 — per-actor internal hash-chain consistency
# ---------------------------------------------------------------------------

#: Hash-chain breaks intermittently until #789 (CaseActor commit-path
#: uniqueness) is fixed. The break position varies across runs, confirming
#: the root cause is non-deterministic commit ordering rather than a
#: reproducible logic error introduced by any single PR.
_CHAIN_XFAIL_789 = pytest.mark.xfail(
strict=False,
reason=(
"Hash-chain inconsistencies due to CaseActor commit-path uniqueness "
"issues (intermittent); will pass when #789 lands"
),
)

_CHAIN_ACTORS = [
pytest.param("case-actor", marks=_CHAIN_XFAIL_789),
pytest.param("vendor", marks=_CHAIN_XFAIL_789),
pytest.param("finder", marks=_CHAIN_XFAIL_789),
pytest.param("case-actor"),
pytest.param("vendor"),
pytest.param("finder"),
]


Expand Down Expand Up @@ -305,21 +293,10 @@ def test_invariant_1_local_hash_chain_consistent(


@pytest.mark.case_ledger_invariants
@pytest.mark.xfail(
strict=False,
reason=(
"Cross-actor hash agreement requires CaseActor commit-path uniqueness; "
"will pass when #789 lands"
),
)
def test_invariant_2_cross_actor_hash_agreement(
case_ledger_replicas: dict[str, list[dict]],
) -> None:
"""All actors agree on the entryHash for every shared logIndex (AC-4.2).

When this xfail is unexpectedly promoted to XPASS, remove the
``xfail`` decorator to make it a permanent regression guard.
"""
"""All actors agree on the entryHash for every shared logIndex (AC-4.2)."""
by_index: dict[int, dict[str, str]] = {}
for actor, entries in case_ledger_replicas.items():
for entry in entries:
Expand All @@ -338,21 +315,10 @@ def test_invariant_2_cross_actor_hash_agreement(


@pytest.mark.case_ledger_invariants
@pytest.mark.xfail(
strict=False,
reason=(
"Cross-actor payloadSnapshot.actor agreement requires CaseActor "
"commit-path uniqueness; will pass when #789 lands"
),
)
def test_invariant_3_cross_actor_payload_actor_agreement(
case_ledger_replicas: dict[str, list[dict]],
) -> None:
"""All actors agree on payloadSnapshot.actor for every shared logIndex (AC-4.3).

When this xfail is unexpectedly promoted to XPASS, remove the
``xfail`` decorator to make it a permanent regression guard.
"""
"""All actors agree on payloadSnapshot.actor for every shared logIndex (AC-4.3)."""
by_index: dict[int, dict[str, str | None]] = {}
for actor, entries in case_ledger_replicas.items():
for entry in entries:
Expand All @@ -375,21 +341,12 @@ def test_invariant_3_cross_actor_payload_actor_agreement(


@pytest.mark.case_ledger_invariants
@pytest.mark.xfail(
strict=False,
reason=(
"Non-empty payloadSnapshot requires the commit-boundary guard and "
"removal of synthetic checkpoint events (see issue #789)"
),
)
def test_invariant_4_non_empty_payload_snapshot(
case_ledger_replicas: dict[str, list[dict]],
) -> None:
"""Every recorded canonical entry has a non-empty payloadSnapshot (AC-4.4).

Rejection entries (``disposition != "recorded"``) are excluded.
When this xfail is unexpectedly promoted to XPASS, remove the
``xfail`` decorator to make it a permanent regression guard.
"""
empty = [
f"Actor {actor!r} logIndex={_log_index(e)} eventType={_event_type(e)!r}"
Expand All @@ -404,22 +361,13 @@ def test_invariant_4_non_empty_payload_snapshot(


@pytest.mark.case_ledger_invariants
@pytest.mark.xfail(
strict=False,
reason=(
"All expected protocol eventTypes require the CaseActor commit-path "
"implementation; will pass when #789 ACs are satisfied"
),
)
def test_invariant_5_expected_event_types_present(
case_ledger_replicas: dict[str, list[dict]],
) -> None:
"""All expected protocol eventTypes appear at least once (AC-4.5).

Checked against the ``case-actor`` replica (authoritative log).
Falls back to any available replica when no ``case-actor`` key exists.
When this xfail is unexpectedly promoted to XPASS, remove the
``xfail`` decorator to make it a permanent regression guard.
"""
auth = _auth_entries(case_ledger_replicas)
found = {_event_type(e) for e in auth}
Expand Down Expand Up @@ -471,8 +419,6 @@ def test_invariant_7_log_terminates_all_rm_closed(
"""The log terminates with every participant in RM=CLOSED (AC-4.7).

Checks the final ``add_participant_status`` entry per participant.
When this xfail is unexpectedly promoted to XPASS, remove the
``xfail`` decorator to make it a permanent regression guard.
"""
auth = _auth_entries(case_ledger_replicas)
latest_rm: dict[str, str] = {}
Expand Down Expand Up @@ -536,21 +482,10 @@ def test_invariant_8_late_joiner_has_full_history(


@pytest.mark.case_ledger_invariants
@pytest.mark.xfail(
strict=False,
reason=(
"ParticipantStatus entries must include emConsentState and cvdRole; "
"requires the ParticipantStatus schema completeness fix (see issue #789)"
),
)
def test_invariant_9_participant_status_schema_completeness(
case_ledger_replicas: dict[str, list[dict]],
) -> None:
"""Every ParticipantStatus snapshot includes emConsentState and cvdRole (AC-4.9).

When this xfail is unexpectedly promoted to XPASS, remove the
``xfail`` decorator to make it a permanent regression guard.
"""
"""Every ParticipantStatus snapshot includes emConsentState and cvdRole (AC-4.9)."""
auth = _auth_entries(case_ledger_replicas)
status_entries = [
e for e in auth if _event_type(e) == "add_participant_status"
Expand Down
14 changes: 4 additions & 10 deletions test/core/behaviors/case/nodes/participant/test_owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
AttachOwnerParticipantToCaseNode,
CreateOwnerParticipantNode,
PersistOwnerCaseNode,
RecordOwnerJoinedEventNode,
ResolveOwnerInitialStatusNode,
ShouldAdvanceOwnerToAcceptedNode,
)
Expand Down Expand Up @@ -155,16 +154,15 @@ def test_config_roles_combined_with_case_owner(
def test_is_composed_subtree_of_named_leaf_nodes(self) -> None:
node = CreateCaseOwnerParticipant()
assert isinstance(node, py_trees.composites.Sequence)
assert [type(child) for child in node.children[:5]] == [
assert [type(child) for child in node.children[:4]] == [
ResolveOwnerInitialStatusNode,
CreateOwnerParticipantNode,
AttachOwnerParticipantToCaseNode,
PersistOwnerCaseNode,
RecordOwnerJoinedEventNode,
]
assert isinstance(node.children[5], py_trees.composites.Selector)
assert isinstance(node.children[4], py_trees.composites.Selector)

conditional_selector = node.children[5]
conditional_selector = node.children[4]
assert isinstance(
conditional_selector.children[0], py_trees.composites.Sequence
)
Expand All @@ -185,18 +183,14 @@ def test_records_owner_joined_event(
case_obj: VultronCase,
actor_id: str,
) -> None:
"""CreateCaseOwnerParticipant records 'owner_joined' on the case."""
"""CreateCaseOwnerParticipant succeeds (owner_joined no longer written to CaseEvent)."""
result = bt_scenario.run(
CreateCaseOwnerParticipant(),
actor_id=actor_id,
case_id=case_obj.id_,
)
bt_scenario.assert_success(result)

stored_case = cast(Any, bt_scenario.dl.read(case_obj.id_))
event_types = [e.event_type for e in stored_case.events]
assert "owner_joined" in event_types

def test_advances_owner_rm_to_accepted_when_configured(
self,
bt_scenario: BTTestScenario,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
CaseHasNoActiveEmbargoNode,
CreateParticipantNode,
QueueAddParticipantNotificationNode,
RecordParticipantAddedEventNode,
ResolveParticipantAcceptedStatusNode,
SeedParticipantAsSignatoryIfEmbargoActiveNode,
SeedParticipantAsSignatoryNode,
Expand Down Expand Up @@ -71,27 +70,6 @@ def test_creates_and_attaches_participant(
stored_case = cast(Any, bt_scenario.dl.read(case_obj.id_))
assert finder_actor_id in stored_case.actor_participant_index

def test_records_participant_added_event(
self,
bt_scenario: BTTestScenario,
actor: VultronCaseActor,
case_obj: VultronCase,
actor_id: str,
finder_actor_id: str,
) -> None:
"""CreateCaseParticipantNode records 'participant_added' on the case."""
bt_scenario.run(
CreateCaseParticipantNode(
actor_id=finder_actor_id, roles=[CVDRole.FINDER]
),
actor_id=actor_id,
case_id=case_obj.id_,
)

stored_case = cast(Any, bt_scenario.dl.read(case_obj.id_))
event_types = [e.event_type for e in stored_case.events]
assert "participant_added" in event_types

def test_is_composed_subtree_of_named_leaf_nodes(self) -> None:
node = CreateCaseParticipantNode(
actor_id="https://example.org/actors/finder",
Expand All @@ -102,7 +80,6 @@ def test_is_composed_subtree_of_named_leaf_nodes(self) -> None:
ResolveParticipantAcceptedStatusNode,
CreateParticipantNode,
AttachParticipantToCaseNode,
RecordParticipantAddedEventNode,
SeedParticipantAsSignatoryIfEmbargoActiveNode,
QueueAddParticipantNotificationNode,
]
Expand Down
Loading
Loading