Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions plan/history/2608/idea/IDEA-1785.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
source: IDEA-1785
timestamp: '2026-08-10T19:27:01.701836+00:00'
title: Rename verify_fix_deployed() to clarify it applies to DEPLOYER actors only
type: idea
---

## Problem

`vultron/demo/helpers/milestones.py::verify_fix_deployed()` asserts
`CVDRole.VENDOR` in participant roles, then checks `vfd_state == VFD`.

After enforcing CSB-15-002 (only DEPLOYER actors may advance to VFD), calling
`verify_fix_deployed()` with a vendor-only actor fails at the VENDOR role check
before reaching the VFD state assertion — giving a confusing error
("actor does not hold CVDRole.VENDOR") when the real issue is that vendor-only
actors legitimately terminate at `VFd`, not `VFD`.

## Proposed Fix

1. Rename `verify_fix_deployed` → `verify_fix_deployed_for_deployer`.
2. Update its docstring and role check to require `CVDRole.DEPLOYER` (not
`CVDRole.VENDOR` — DEPLOYER actors need not be VENDOR).
3. Update all demo scripts and milestone helpers that call `verify_fix_deployed`.
4. Add `verify_fix_ready_for_vendor()` as a companion that checks
`vfd_state == VFd` for VENDOR-only actors.

## Resolution

After grill-me, the rename was judged tautological (fix deployed implies DEPLOYER).
Instead: tightened the role guard to DEPLOYER-only (drop VENDOR fallback), kept
the function name, and updated DEMOMA-15-001 and DEMOMA-19-004 in the spec.

**Processed**: 2026-08-10 — implementation tracked in #2148.
Docs PR: <https://github.com/CERTCC/Vultron/pull/2147>.
Spec: `specs/multi-actor-demo.yaml`.
35 changes: 17 additions & 18 deletions specs/multi-actor-demo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1798,23 +1798,23 @@ groups:
`verify_fix_ready` in `vultron/demo/helpers/milestones.py` MUST accept only
actor IDs holding `CVDRole.VENDOR` in the case's participant index; the f→F
(fix-ready) transition is gated on VENDOR by `CheckVendorRoleNode`.
`verify_fix_deployed` MUST accept actor IDs holding `CVDRole.VENDOR` OR
`CVDRole.DEPLOYER`; the d→D (fix-deployed) transition is gated on DEPLOYER
by `CheckDeployerRoleNode`. Passing any other actor (e.g. COORDINATOR or
CASE_OWNER) to either helper is a caller error. Each helper MUST enforce its
precondition at runtime, raising `AssertionError` with the actor ID and its
actual roles if the assertion fails.
`verify_fix_deployed` MUST accept only actor IDs holding `CVDRole.DEPLOYER`
in the case's participant index; the d→D (fix-deployed) transition is gated
on DEPLOYER by `CheckDeployerRoleNode`. Passing any other actor (e.g.
COORDINATOR, CASE_OWNER, or a VENDOR-only actor without DEPLOYER) to either
helper is a caller error. Each helper MUST enforce its precondition at
runtime, raising `AssertionError` with the actor ID and its actual roles if
the assertion fails.
rationale: >-
The f→F (fix-ready) transition requires CVDRole.VENDOR; coordinators have no
vendor fix path. The d→D (fix-deployed) transition requires CVDRole.DEPLOYER;
a vendor who is not a deployer stops at VFd. An actor may hold both roles
(e.g. [vendor, deployer]) and passes both checks. The original spec listed
VENDOR for both helpers, which was an overfit to single-vendor scenarios
where Vendor and Deployer were always the same actor. The FCVCV scenario
introduced a distinct Vendor-Deployer actor (V2) that makes the distinction
observable. This was the root cause of the M4/M5 CI failures in PR #1623
(issue #1593), where `fcv_demo.py` passed `coordinator.id_` instead of
`vendor.id_`.
a vendor who is not a deployer stops at VFd and cannot advance to VFD
(CSB-15-002). An actor may hold both VENDOR and DEPLOYER roles and passes
both checks. `verify_fix_deployed` previously accepted VENDOR-or-DEPLOYER,
which was misleading: a VENDOR-only actor can never reach VFD regardless of
the role guard, so accepting VENDOR in the guard gave a confusing error
(VFD state check failure) instead of a clear one (DEPLOYER role check
failure). Requiring DEPLOYER-only makes the failure mode unambiguous.
relationships:
- rel_type: refines
spec_id: DEMOMA-06-002
Expand Down Expand Up @@ -2303,11 +2303,10 @@ groups:
rationale: >-
V1's vendor-only role set (stops at VFd) and V2's vendor-deployer role
set (reaches VFD) are the key differentiators of this scenario. The
distinction verifies DEMOMA-15-001's corrected precondition guards:
distinction verifies DEMOMA-15-001's precondition guards:
`verify_fix_ready` requires VENDOR; `verify_fix_deployed` requires
VENDOR or DEPLOYER. Using V1 in a `verify_fix_deployed` call would
exercise the wrong path and mask a future regression where the d→D BT
gate is weakened.
DEPLOYER. Using V1 in a `verify_fix_deployed` call would exercise the
wrong path and mask a future regression where the d→D BT gate is weakened.
relationships:
- rel_type: refines
spec_id: DEMOMA-15-001
Expand Down
105 changes: 46 additions & 59 deletions test/demo/test_milestones_vendor_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the
# U.S. Patent and Trademark Office by Carnegie Mellon University

"""Unit tests for the CVDRole.VENDOR precondition guard in verify_fix_ready
and verify_fix_deployed (DEMOMA-15-001).
"""Unit tests for the precondition guards in verify_fix_ready and
verify_fix_deployed (DEMOMA-15-001).

verify_fix_ready requires CVDRole.VENDOR.
verify_fix_deployed requires CVDRole.DEPLOYER (VENDOR-only actors do not pass).

These tests exercise the guard in isolation using mocked _fetch_participant
calls, without spinning up a FastAPI server.
Expand Down Expand Up @@ -178,10 +181,15 @@ def test_vendor_with_case_owner_passes_guard(self):
# ---------------------------------------------------------------------------


class TestVerifyFixDeployedVendorGuard:
"""DEMOMA-15-001: verify_fix_deployed raises when actor holds neither CVDRole.VENDOR nor CVDRole.DEPLOYER."""
class TestVerifyFixDeployedDeployerGuard:
"""DEMOMA-15-001: verify_fix_deployed requires CVDRole.DEPLOYER.

def test_non_vendor_actor_raises_assertionerror(self):
VENDOR-only actors do not pass — a VENDOR without DEPLOYER can never
reach VFD (CSB-15-002), so accepting VENDOR here would produce a
confusing state-check failure instead of a clear role-check failure.
"""

def test_coordinator_raises_assertionerror(self):
"""Passing a Coordinator actor ID raises AssertionError."""
participant = _make_participant_mock([CVDRole.COORDINATOR])

Expand All @@ -199,9 +207,9 @@ def test_non_vendor_actor_raises_assertionerror(self):

msg = str(exc_info.value)
assert _COORDINATOR_ACTOR_ID in msg
assert "CVDRole.VENDOR" in msg
assert "CVDRole.DEPLOYER" in msg

def test_non_vendor_error_includes_actual_roles(self):
def test_non_deployer_error_includes_actual_roles(self):
"""AssertionError message includes the actor's actual roles."""
participant = _make_participant_mock([CVDRole.FINDER])

Expand All @@ -218,31 +226,15 @@ def test_non_vendor_error_includes_actual_roles(self):
)

msg = str(exc_info.value)
assert "CVDRole.VENDOR" in msg
assert "CVDRole.DEPLOYER" in msg

def test_vendor_actor_does_not_raise_guard(self):
"""A VENDOR actor passes the guard (state check proceeds normally)."""
def test_vendor_only_raises_assertionerror(self):
"""VENDOR-only actor raises AssertionError (CSB-15-002: stops at VFd)."""
participant = _make_participant_mock([CVDRole.VENDOR])

with patch(
"vultron.demo.helpers.milestones._fetch_participant",
return_value=participant,
), patch(
"vultron.demo.helpers.milestones._check_participant_vfd_state_in"
):
# Should not raise
verify_fix_deployed(
MagicMock(),
MagicMock(),
_CASE_ID,
_VENDOR_ACTOR_ID,
)

def test_missing_participant_raises(self):
"""If the actor is not found in the case, AssertionError is raised."""
with patch(
"vultron.demo.helpers.milestones._fetch_participant",
return_value=None,
):
with pytest.raises(AssertionError) as exc_info:
verify_fix_deployed(
Expand All @@ -252,10 +244,12 @@ def test_missing_participant_raises(self):
_VENDOR_ACTOR_ID,
)

assert _VENDOR_ACTOR_ID in str(exc_info.value)
msg = str(exc_info.value)
assert _VENDOR_ACTOR_ID in msg
assert "CVDRole.DEPLOYER" in msg

def test_case_owner_only_raises_assertionerror(self):
"""CASE_OWNER alone raises AssertionError (the CI regression scenario)."""
"""CASE_OWNER alone raises AssertionError."""
participant = _make_participant_mock([CVDRole.CASE_OWNER])

with patch(
Expand All @@ -272,32 +266,27 @@ def test_case_owner_only_raises_assertionerror(self):

msg = str(exc_info.value)
assert _VENDOR_ACTOR_ID in msg
assert "CVDRole.VENDOR" in msg

def test_vendor_with_case_owner_passes_guard(self):
"""VENDOR + CASE_OWNER (the real demo scenario after the fix) passes."""
participant = _make_participant_mock(
[CVDRole.VENDOR, CVDRole.CASE_OWNER]
)
assert "CVDRole.DEPLOYER" in msg

def test_missing_participant_raises(self):
"""If the actor is not found in the case, AssertionError is raised."""
with patch(
"vultron.demo.helpers.milestones._fetch_participant",
return_value=participant,
), patch(
"vultron.demo.helpers.milestones._check_participant_vfd_state_in"
return_value=None,
):
verify_fix_deployed(
MagicMock(),
MagicMock(),
_CASE_ID,
_VENDOR_ACTOR_ID,
)
with pytest.raises(AssertionError) as exc_info:
verify_fix_deployed(
MagicMock(),
MagicMock(),
_CASE_ID,
_DEPLOYER_ACTOR_ID,
)

def test_vendor_and_deployer_passes_guard(self):
"""AC-5c: VENDOR+DEPLOYER actor passes the guard — the V2 case."""
participant = _make_participant_mock(
[CVDRole.VENDOR, CVDRole.DEPLOYER]
)
assert _DEPLOYER_ACTOR_ID in str(exc_info.value)

def test_deployer_only_passes_guard(self):
"""DEPLOYER-only actor passes the guard (state check proceeds normally)."""
participant = _make_participant_mock([CVDRole.DEPLOYER])

with patch(
"vultron.demo.helpers.milestones._fetch_participant",
Expand All @@ -309,16 +298,14 @@ def test_vendor_and_deployer_passes_guard(self):
MagicMock(),
MagicMock(),
_CASE_ID,
_VENDOR_ACTOR_ID,
_DEPLOYER_ACTOR_ID,
)

def test_deployer_only_passes_guard(self):
"""AC-5d: DEPLOYER-only actor passes _assert_deployer_or_vendor_role.

The guard requires VENDOR *or* DEPLOYER (DEMOMA-15-001); a
DEPLOYER-only actor satisfies the OR condition and passes.
"""
participant = _make_participant_mock([CVDRole.DEPLOYER])
def test_vendor_and_deployer_passes_guard(self):
"""VENDOR+DEPLOYER actor passes the guard — the V2 case in FCVCV."""
participant = _make_participant_mock(
[CVDRole.VENDOR, CVDRole.DEPLOYER]
)

with patch(
"vultron.demo.helpers.milestones._fetch_participant",
Expand All @@ -335,10 +322,10 @@ def test_deployer_only_passes_guard(self):


class TestVerifyFixReadyDeployerGuard:
"""AC-5e: verify_fix_ready raises for DEPLOYER-only actor (no VENDOR)."""
"""verify_fix_ready raises for DEPLOYER-only actor (no VENDOR)."""

def test_deployer_only_raises_assertionerror(self):
"""AC-5e: DEPLOYER-only actor raises AssertionError in verify_fix_ready.
"""DEPLOYER-only actor raises AssertionError in verify_fix_ready.

verify_fix_ready uses _assert_vendor_role which requires CVDRole.VENDOR.
A DEPLOYER-only actor does not hold VENDOR and must be rejected.
Expand Down
38 changes: 18 additions & 20 deletions vultron/demo/helpers/milestones.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,18 +211,18 @@ def verify_fix_ready(
logger.info("✓ fix ready: both replicas show CS includes F (fix ready)")


def _assert_deployer_or_vendor_role(
def _assert_deployer_role(
client: DataLayerClient,
case_id: str,
actor_id: str,
label: str,
) -> None:
"""Assert that *actor_id* holds ``CVDRole.DEPLOYER`` or ``CVDRole.VENDOR``.
"""Assert that *actor_id* holds ``CVDRole.DEPLOYER`` in the case.

The d→D (fix-deployed) transition is gated on ``CVDRole.DEPLOYER`` by
``CheckDeployerRoleNode``; an actor that holds only ``CVDRole.VENDOR`` but
not ``CVDRole.DEPLOYER`` stops at VFd and cannot reach VFD. An actor may
hold both roles and passes this check.
``CheckDeployerRoleNode`` (CSB-15-002). A VENDOR-only actor cannot reach
VFD regardless, so accepting VENDOR here would only produce a confusing
VFD-state-check failure downstream instead of a clear role-check failure.

Spec: DEMOMA-15-001.

Expand All @@ -233,20 +233,19 @@ def _assert_deployer_or_vendor_role(
label: Human-readable label for the ``AssertionError`` message.

Raises:
AssertionError: If the participant is not found or holds neither
``CVDRole.DEPLOYER`` nor ``CVDRole.VENDOR``.
AssertionError: If the participant is not found or does not hold
``CVDRole.DEPLOYER``.
"""
participant = _fetch_participant(client, case_id, actor_id)
if participant is None:
raise AssertionError(
f"{label}: actor {actor_id!r} not found as a participant in case"
f" {case_id!r}"
)
vfd_roles = {CVDRole.VENDOR, CVDRole.DEPLOYER}
if not (vfd_roles & set(participant.case_roles or [])):
if CVDRole.DEPLOYER not in (participant.case_roles or []):
raise AssertionError(
f"{label}: actor {actor_id!r} holds neither CVDRole.VENDOR nor"
f" CVDRole.DEPLOYER; actual roles: {participant.case_roles!r}"
f"{label}: actor {actor_id!r} does not hold CVDRole.DEPLOYER;"
f" actual roles: {participant.case_roles!r}"
)


Expand All @@ -259,11 +258,10 @@ def verify_fix_deployed(
"""Verify that both replicas show CS includes D (fix deployed).

The ``receiver_actor_id`` MUST be the full URI of an actor holding
``CVDRole.DEPLOYER`` or ``CVDRole.VENDOR`` in the case. The d→D
transition is gated on ``CVDRole.DEPLOYER``; an actor with only
``CVDRole.VENDOR`` and no deployer role cannot advance beyond VFd.
Passing any other actor (e.g. a Coordinator) is a caller error and will
raise ``AssertionError`` before the state check runs.
``CVDRole.DEPLOYER`` in the case. The d→D transition is gated on
``CVDRole.DEPLOYER`` by ``CheckDeployerRoleNode`` (CSB-15-002); a
VENDOR-only actor cannot advance beyond VFd regardless, so passing one
here is a caller error that will raise ``AssertionError`` immediately.

Specs: DEMOMA-06-002, DEMOMA-15-001.

Expand All @@ -275,11 +273,11 @@ def verify_fix_deployed(
participant vfd_state to check.

Raises:
AssertionError: If ``receiver_actor_id`` holds neither
``CVDRole.DEPLOYER`` nor ``CVDRole.VENDOR``, or if either
replica does not reflect fix-deployed state.
AssertionError: If ``receiver_actor_id`` does not hold
``CVDRole.DEPLOYER``, or if either replica does not reflect
fix-deployed state.
"""
_assert_deployer_or_vendor_role(
_assert_deployer_role(
receiver_client, case_id, receiver_actor_id, "verify_fix_deployed"
)
deployed_state = {CS_vfd.VFD}
Expand Down