Skip to content
Reference

rv Demo — Protocol Reference

This page is the message-level reference for the Reporter + Vendor (rv) demo scenario. It records, phase by phase, which trigger the demo runner calls, which ActivityStreams Vocabulary 2.0 (AS2) activities the actors then exchange, and which case ledger entries the exchange produces. It is written for developers who are building actors that must interoperate with the Vultron Protocol, and it was verified against a run of the scenario.

The page does not explain how to run the demo or why the case moves as it does. Run the rv Demo carries the run-it-yourself steps, and the rv scenario narrative carries the explanation in Coordinated Vulnerability Disclosure (CVD) terms. Each activity table links the occasion section on the Message Types pages that documents the activity, with its canonical example.


Actors and topology

The compose stack starts every actor container the multi-actor scenarios share. The rv scenario uses three of them, one per logical actor:

Actor CVD role(s) Container Actor id
Reporter REPORTER reporter http://reporter:7999/api/v2/actors/{id}
Vendor VENDOR, CASE_OWNER vendor http://vendor:7999/api/v2/actors/{id}
Case Actor CASE_MANAGER case-actor http://case-actor:7999/api/v2/actors/case-actor

The Case Actor is the actor that enacts the CASE_MANAGER role for the case. It is the single writer of the canonical case ledger, and every case-scoped activity a participant sends is addressed to it (The CASE_MANAGER and the Case Ledger). It runs on the dedicated case-actor container, because every container's case_actor_service_url names that container (CP-08-008). That container holds the canonical case and ledger, and the demo runner asserts at the end of Phase 2 that it holds the case with its Case Actor as the CASE_MANAGER participant.

The Reporter is not a protocol role. The Reporter actor holds REPORTER, the role of whoever submits the report; the protocol has no separate Finder role (ADR-0078).


Message-by-message sequence

The demo runner drives the scenario through six phases and verifies seven milestones (M1–M7). The sequence diagram below shows every activity exchanged between the three actors, in the order a run produces them. Ledger fan-out, in which the Case Actor sends Announce(CaseLedgerEntry) for every entry it commits, is drawn once per phase rather than once per entry.

---
title: rv demo — activities exchanged
---
sequenceDiagram
    participant F as Reporter
    participant V as Vendor
    participant CA as Case Actor

    note over F,CA: Phase 1 — Report submission and case activation
    F->>V: Offer(VulnerabilityReport) — Report Submission (RS)
    V->>CA: Create(CaseProposal)
    CA->>V: Accept(CaseProposal)
    CA->>V: Create(VulnerabilityCase)
    V->>F: Create(VulnerabilityCase) — introduces the Case Actor
    V->>CA: Accept(Offer(VulnerabilityReport)) — Report Valid (RV)
    V->>CA: Join(VulnerabilityCase) — Report/Case Accepted (RA)
    CA-->>V: Announce(CaseLedgerEntry) ×n
    CA-->>F: Announce(CaseLedgerEntry) ×n
    note over F,CA: ✅ M1 — ≥3 participants · EM.ACTIVE · Reporter holds the case

    note over F,CA: Phase 2 — Replica synchronization verification (no new activity)
    note over F,CA: ✓ M2 — Reporter replica matches the Vendor replica

    note over F,CA: Phase 3 — Notes exchange
    F->>CA: Add(Note, target=VulnerabilityCase) — General Inquiry (GI)
    CA-->>V: Announce(CaseLedgerEntry)
    CA-->>F: Announce(CaseLedgerEntry)
    V->>CA: Add(Note, target=VulnerabilityCase) — reply
    CA-->>V: Announce(CaseLedgerEntry)
    CA-->>F: Announce(CaseLedgerEntry)
    note over F,CA: ✅ M3 — Vendor holds the authoritative final case state

    note over F,CA: Phase 4 — Fix lifecycle
    V->>CA: Add(ParticipantStatus Vf), Add(ParticipantStatus VF) — Vendor Awareness (CV), Fix Readiness (CF)
    CA-->>V: Announce(CaseLedgerEntry) ×n
    CA-->>F: Announce(CaseLedgerEntry) ×n
    note over F,CA: ✅ M4, M5 — both replicas show CS includes F

    note over F,CA: Phase 5 — Publication and embargo teardown
    V->>CA: Add(ParticipantStatus Pxa) — public awareness
    CA->>V: Remove(Event) — Embargo Termination (ET)
    CA->>F: Remove(Event) — Embargo Termination (ET)
    F->>CA: Add(ParticipantStatus Pxa) — public awareness
    CA-->>V: Announce(CaseLedgerEntry) ×n
    CA-->>F: Announce(CaseLedgerEntry) ×n
    note over F,CA: ✅ M6 — CS.VFdPxa · EM.EXITED on both replicas

    note over F,CA: Phase 6 — Case closure
    F->>CA: Leave(VulnerabilityCase) — participant closes
    CA-->>V: Announce(CaseLedgerEntry) ×n — the Reporter's closure
    CA-->>F: Announce(CaseLedgerEntry) ×n — its own closure, then nothing
    V->>CA: Leave(VulnerabilityCase) — owner closes last
    CA-->>V: Announce(CaseLedgerEntry) ×n — its own closure, then nothing
    note over F,CA: ✅ M7 — all participants RM.CLOSED in the Case Actor's store

A run also contains Announce(VulnerabilityCase) from the Case Actor to every participant, which seeds a replica, and Reject(CaseLedgerEntry) from a replica to the Case Actor, which reports that an entry arrived before its predecessor and asks for replay. Both are part of ledger replication rather than of the CVD case, and Ledger Replication Messages documents them.


Phase 1 — Report submission and case activation

Triggers

POST /api/v2/actors/{reporter_id}/trigger/submit-report
POST /api/v2/actors/{vendor_id}/trigger/validate-report
POST /api/v2/actors/{vendor_id}/trigger/engage-case

Activities

Activity Occasion Direction
Offer(VulnerabilityReport) Report Submission (RS) Reporter → Vendor
Create(CaseProposal) Create case proposal Vendor → Case Actor
Accept(CaseProposal) Accept case proposal Case Actor → Vendor
Create(VulnerabilityCase) Create case Case Actor → Vendor
Create(VulnerabilityCase) Create case Vendor → Reporter
Accept(Offer(VulnerabilityReport)) Validate report Vendor → Case Actor
Join(VulnerabilityCase) Report/Case Accepted (RA) Vendor → Case Actor

What happens

  1. The Reporter's submit-report trigger builds Offer(VulnerabilityReport) and its outbox delivers it to the Vendor's inbox.
  2. The Vendor's behavior tree stores the report and sends Create(CaseProposal) to the Case Actor. The Vendor does not create the case itself; case creation is delegated so that the CASE_MANAGER authority is established by the actor that will hold it (ADR-0041, CP-09-005).
  3. The Case Actor creates the case, seats itself as CASE_MANAGER, the Vendor as CASE_OWNER at RM.RECEIVED, and the Reporter as REPORTER at RM.ACCEPTED, initializes the Vendor's default embargo, and seeds both participants' embargo consent as signatories. The embargo is therefore ACTIVE from the first ledger entry without a proposal round-trip (EP-04-001). It then sends Accept(CaseProposal) to the Vendor, followed by Create(VulnerabilityCase) to the Vendor only (CBT-01-010). That Create carries the case with its participants inline. The Vendor accepts it, then sends the Reporter its own Create(VulnerabilityCase), which carries the same case inline, names the Case Actor as CASE_MANAGER, and references the Reporter's report. That introduction is the trust bootstrap: the Reporter trusts it because it comes from the actor the Reporter sent its report to, and it then binds the Case Actor's identity to this case (CBT-01-001, CBT-01-005, ADR-0132).
  4. The Vendor's validate-report trigger sends Report Valid (RV) to the Case Actor. The Case Actor advances the Vendor's participant record to RM.VALID and commits that status after the act, and the Vendor's replica copies it (RSH-08-006).
  5. Once its replica holds RM.VALID, the Vendor's engage-case trigger sends Report/Case Accepted (RA), and the record advances to RM.ACCEPTED the same way.

The case is created before validation, at RM.RECEIVED. Propose a Case explains the cascade the Case Actor runs on acceptance, step by step.

Example: Offer(VulnerabilityReport)

The example below is the activity a run produced, with identifiers shortened. The report travels inline, because the Vendor has no way to read it out of the Reporter's store.

{
  "type": "Offer",
  "id": "urn:uuid:711dacfa-…",
  "actor": "http://reporter:7999/api/v2/actors/{reporter_id}",
  "to": ["http://vendor:7999/api/v2/actors/{vendor_id}"],
  "target": {"type": "Organization", "id": "http://vendor:7999/api/v2/actors/{vendor_id}"},
  "object": {
    "type": "VulnerabilityReport",
    "id": "urn:uuid:3fed4100-…",
    "name": "Remote Code Execution in Network Stack",
    "content": "A critical remote code execution vulnerability was discovered…",
    "attributedTo": "http://reporter:7999/api/v2/actors/{reporter_id}"
  }
}

Example: Create(VulnerabilityCase)

The context names the new case and the inReplyTo names the Accept(CaseProposal) that authorized it (CP-05-003). The case object is abbreviated here; on the wire it carries every participant inline (CBT-01-012). The actor is the Case Actor, acting as CASE_MANAGER, while the case's attributedTo names the Vendor, the Case Owner (CP-09-001).

{
  "type": "Create",
  "id": "urn:uuid:ac0e2f61-…",
  "actor": "http://case-actor:7999/api/v2/actors/case-actor",
  "to": ["http://vendor:7999/api/v2/actors/{vendor_id}"],
  "context": "urn:uuid:8eee4e72-…",
  "inReplyTo": "urn:uuid:15f958ea-…",
  "object": {
    "type": "VulnerabilityCase",
    "id": "urn:uuid:8eee4e72-…",
    "attributedTo": "http://vendor:7999/api/v2/actors/{vendor_id}"
  }
}

Milestone M1

Check Where
Case exists both replicas
Participant count ≥ 3 (Vendor, Reporter, Case Actor) both replicas
EM state = ACTIVE, default embargo present both replicas
Reporter holds the case Reporter replica

Phase 2 — Replica synchronization verification

Triggers

None. This phase reads the two replicas and sends nothing.

What happens

  1. The demo runner reads the highest ledger index the Vendor's replica holds.
  2. It waits until the Reporter's replica holds a contiguous ledger up to that index.
  3. It compares the two replicas: the participant index, the active embargo id, and the ledger tail hash must match.
  4. It reads the dedicated case-actor container and asserts that it holds the case, with its Case Actor as a CASE_MANAGER participant.

Ledger entries reach a replica through Announce(CaseLedgerEntry), one activity per entry per participant. Entries can arrive out of order; a replica that receives an entry ahead of its predecessor answers with Reject(CaseLedgerEntry), and the Case Actor replays the missing range (Ledger Replication Messages).

Milestone M2

Check Where
Ledger coverage contiguous to the Vendor's tail Reporter replica
Matching actorParticipantIndex both replicas
Matching active embargo (the register's ACTIVE entry) both replicas
Matching ledger tail hash both replicas
Case held, Case Actor is CASE_MANAGER dedicated case-actor container

Phase 3 — Notes exchange

Triggers

POST /api/v2/actors/{reporter_id}/demo/add-note-to-case
POST /api/v2/actors/{vendor_id}/demo/add-note-to-case

Activities

Activity Occasion Direction
Add(Note)[target=VulnerabilityCase] Add note to case Participant → Case Actor
Announce(CaseLedgerEntry) Announce case ledger entry Case Actor → every other participant

What happens

  1. The Reporter's trigger sends one Add(Note) to the Case Actor. The Add names the case as its target and carries the note inline as its object.
  2. The Case Actor stores the note, attaches it to the case and commits the Add(Note) as an add_note_to_case ledger entry. It fans the entry out, and each replica attaches the note when it applies the entry.
  3. The Vendor replies the same way, with the reply note's inReplyTo naming the question.
  4. Both replicas end up holding both notes.

Milestone M3

Check Where
Case holds the report, the participants, and both notes Vendor replica

Phase 4 — Fix lifecycle

Triggers

POST /api/v2/actors/{vendor_id}/demo/notify-fix-ready

Activities

Activity Occasion Direction
Add(ParticipantStatus)[target=CaseParticipant], vfState = Vf Vendor Awareness (CV) Vendor → Case Actor
Add(ParticipantStatus)[target=CaseParticipant], vfState = VF Fix Readiness (CF) Vendor → Case Actor

What happens

  1. The notify-fix-ready trigger sends one Add(ParticipantStatus) activity per hop of the vendor-path state the Vendor still needs: vfd → Vfd (vendor aware), then Vfd → VFd (fix ready). The hops are separate because the Case State machine admits one event per step. In rv the Vendor owns the case and starts at vfd, so both hops are sent; an invited vendor is already at Vfd when it accepts its invite, and sends only the second.
  2. The Case Actor validates the transition, appends the status to the Vendor's participant record, commits an add_participant_status_to_participant entry, and fans it out. The Vendor's own replica changes only when it copies that entry: the trigger checks and sends each hop but writes none (RSH-08-006).
  3. The demo does not call notify-fix-deployed. The D dimension belongs to a Deployer, and a vendor-only participant stops at VFd.

Milestones M4 and M5

Milestone Check Where
M4 Vendor's case state includes F (fix ready) both replicas
M5 Vendor's case state is still VFd after the Reporter's replica caught up both replicas

Phase 5 — Publication and embargo teardown

Triggers

POST /api/v2/actors/{vendor_id}/demo/notify-published
POST /api/v2/actors/{reporter_id}/demo/notify-published

Activities

Activity Occasion Direction
Add(ParticipantStatus)[target=CaseParticipant], caseStatus.pxaState = Pxa Public awareness in a participant status Vendor → Case Actor; Reporter → Case Actor
Remove(Event) Tell a participant the embargo has ended Case Actor → Vendor, Reporter

What happens

  1. The Vendor's trigger sends Add(ParticipantStatus) with the public-path state Pxa.
  2. The Case Actor records the status and, because the report of public awareness comes from the Case Owner, terminates the embargo: it sends Remove(Event) to every participant and commits an add_case_status_to_case entry carrying EM.EXITED.
  3. The Reporter's trigger sends its own Add(ParticipantStatus) with Pxa.

Milestone M6

Check Where
Case state VFdPxa both replicas
EM state EXITED both replicas
Vendor participant is public-aware both replicas

Phase 6 — Case closure

Triggers

POST /api/v2/actors/{reporter_id}/trigger/leave-case
POST /api/v2/actors/{vendor_id}/trigger/leave-case

Activities

Activity Occasion Direction
Leave(VulnerabilityCase) Close case Reporter → Case Actor; Vendor → Case Actor

What happens

Closure flows through Leave(VulnerabilityCase), not through a participant asserting its own RM.CLOSED (ADR-0050, CM-23-001).

The Case Owner leaves last (CM-23-015). The owner's departure closes the case, and the Case Actor records nothing a participant sends after that (CM-23-013).

  1. The Reporter sends Leave(VulnerabilityCase). As a non-owner, this advances only the Reporter (CM-23-003); no other participant's RM state changes (CM-23-012). The Case Actor commits close_case for the Reporter. It then moves the Reporter to RM.CLOSED and commits one add_participant_status_to_participant entry for each RM transition it writes, in order (CM-23-001). The Reporter is at RM.ACCEPTED, so that is one entry; a participant at RM.VALID would get two, for VALID → DEFERRED and DEFERRED → CLOSED (RMB-14-005).
  2. The demo waits until the Case Actor's own store shows the Reporter at RM.CLOSED. A trigger returns before its effect commits, so the demo does not let the Vendor's Leave overtake the Reporter's.
  3. The Vendor sends Leave(VulnerabilityCase). The Vendor is the CASE_OWNER, so this is an owner closure (CM-23-002). The Case Actor commits close_case for the Vendor. It expires every embargo invitation still waiting for an answer, with one entry each (CM-23-014); the rv run has none. It then commits the Vendor's status entries. Last, it advances its own participant record to RM.CLOSED and commits those status entries too (CM-23-005). Its own status entry that reaches RM.CLOSED is the write boundary: the case is now fully closed (CM-23-013).
  4. Each entry is fanned out as Announce(CaseLedgerEntry) to every participant that has not closed, and each replica applies what it receives. A closed participant receives its own closure entries and nothing after them (CM-23-004). The leaving participant's replica receives its close_case entry and every status entry for its departure, the one that reaches RM.CLOSED included. The Reporter therefore receives none of the Vendor's closure entries. The Vendor receives its own close_case and status entries, but not the Case Actor's own status entry. A replica moves a departing participant only by applying those status entries: the close_case entry itself changes no RM state, and a replica never works out the steps from its own stored state (CM-23-016). A participant does not write its own RM.CLOSED; it waits for the Case Actor's entries.

Example: Leave(VulnerabilityCase)

{
  "type": "Leave",
  "id": "urn:uuid:541b09bc-…",
  "actor": "http://vendor:7999/api/v2/actors/{vendor_id}",
  "to": ["http://case-actor:7999/api/v2/actors/case-actor"],
  "object": {
    "type": "VulnerabilityCase",
    "id": "urn:uuid:8eee4e72-…",
    "attributedTo": "http://vendor:7999/api/v2/actors/{vendor_id}"
  }
}

Milestone M7

Check Where
Every departure recorded before the Case Actor's own RM.CLOSED status entry, and no participant act after it Case Actor's ledger
Every participant RM.CLOSED, including the CASE_MANAGER Case Actor's store
Ledger contiguous through the Reporter's own last closure entry, and nothing after it; the Reporter is RM.CLOSED Reporter replica
Ledger contiguous through the Vendor's own last closure entry, and nothing after it; the Vendor is RM.CLOSED Vendor replica

No participant replica is checked for every closure: a closed participant is sent nothing after its own closure entries (CM-23-004), so the Case Actor's store is where every closure is verified.


The case ledger a run produces

The Case Actor commits the entries below, in this order. Each entry's semanticType names the protocol act; the actor column names the participant on whose behalf it was recorded. The exact count of entries in the initialization block depends on how many participant records and statuses the acceptance cascade writes, so the table shows the shape of one run rather than an invariant. Each act that moves a participant's RM state is followed by the status the Case Actor wrote for it, which every replica copies (CLP-07-013). A case status follows a participant status only when it changes the case's EM or PXA: one that would repeat the case status before it is not committed (RSH-04-004).

Index semanticType Recorded for Phase
0 create_case Case Actor 1
1 create_embargo_event Case Actor (the default embargo, in full) 1
2 add_report_to_case Case Actor 1
3–7 create_case_participant, add_participant_status_to_participant Case Actor (each of the three participant records as it was created, then the Case Actor's own VALID and ACCEPTED) 1
8 add_case_status_to_case Vendor (initial case status, EM.ACTIVE) 1
9 validate_report Vendor 1
10 add_participant_status_to_participant Case Actor (the Vendor's VALID it recorded) 1
11 engage_case Vendor 1
12 add_participant_status_to_participant Case Actor (the Vendor's ACCEPTED it recorded) 1
13 add_note_to_case Reporter 3
14 add_note_to_case Vendor 3
15–16 add_participant_status_to_participant Vendor's Vf and VF statuses; neither changes the case's EM or PXA, so no case status follows 4
17–18 add_participant_status_to_participant, add_case_status_to_case Vendor's Pxa status and the case status that follows it 5
19–20 remove_embargo_event_from_case, add_case_status_to_case Case Actor (embargo teardown; the case status carrying EM.EXITED) 5
21 add_participant_status_to_participant Reporter's Pxa status; the case already holds P, so no case status follows 5
22 close_case Reporter 6
23 add_participant_status_to_participant Case Actor (the Reporter's ACCEPTED → CLOSED) 6
24 close_case Vendor 6
25 add_participant_status_to_participant Case Actor (the Vendor's ACCEPTED → CLOSED) 6
26 add_participant_status_to_participant Case Actor (its own ACCEPTED → CLOSED, the write boundary) 6

The ledger ends at the Case Actor's own RM.CLOSED status entry: the Reporter left first, so no participant act follows the owner closure. No separate entry marks the closure (ADR-0152).

Each participant's replica holds the same sequence, entry for entry, up to the last entry recording its own closure, and nothing after it (CM-23-004). The Reporter's replica ends at index 23, its own closure step. The Vendor's replica ends at index 25, its own closure step, so it does not hold the Case Actor's own status entry. The demo runner exports all three ledgers as JSONL under devlogs/rv/ at the end of a run, and the invariant harness in test/ci/invariants/ checks them against the causal edges the rv scenario narrative declares.


State machine summary

The protocol defines five state machines: Report Management (RM), Embargo Management (EM), Participant Embargo Consent (PEC), and the two Case State (CS) sub-machines, the vendor path (VFD) and the public path (PXA). The table shows the states the rv run traverses in each.

Machine Path the run traverses Notes
RM (Vendor) RECEIVED → VALID → ACCEPTED → CLOSED driven by validate-report, engage-case, leave-case
RM (Reporter) ACCEPTED → CLOSED seated at ACCEPTED on creation (CBT-01-008)
RM (Case Actor) RECEIVED → VALID → ACCEPTED → CLOSED its own record tracks the proposal and the owner closure (CM-23-005)
EM ACTIVE → EXITED the default embargo is active from creation; PROPOSED is never entered
PEC signatory for both participants consent row for the default embargo seeded AGREED on creation; no invitation round-trip
CS vendor path (VFD) vfd → Vfd → VFd D is not demonstrated; the Vendor stops at fix ready
CS public path (PXA) pxa → Pxa X and A are not demonstrated

Process Models explains the RM, EM, and CS machines, and The Embargo Lifecycle covers PEC.


Puppeteering constraint

The demo runner never constructs or sends an AS2 activity to any actor's inbox. It only:

  • calls trigger endpoints on each actor's own container (for example POST /api/v2/actors/{id}/demo/notify-fix-ready), and
  • reads DataLayer endpoints for milestone verification (for example GET /api/v2/actors/{id}/datalayer/{case_id}).

Every activity in the trace above is therefore produced by an actor's own behavior tree and delivered by its outbox. The demo is a live protocol run, not a simulation.


Key implementation files

File Role
vultron/demo/scenario/rv_demo.py Demo orchestration script
vultron/demo/helpers/ Shared helper modules for scenario scripts: actor roles, seeding, causal polling and gating, verification, and workflow emission
vultron/adapters/driving/fastapi/routers/trigger_report.py, trigger_case.py Protocol trigger endpoints (submit-report, validate-report, engage-case, leave-case)
vultron/adapters/driving/fastapi/routers/demo_triggers.py Demo-only trigger endpoints (add-note-to-case, notify-*)
vultron/adapters/driving/fastapi/routers/actors/ Inbox endpoint
vultron/wire/as2/extractor/ Activity pattern matching (AS2 → MessageSemantics)
vultron/core/dispatcher.py Message routing (MessageSemantics → use case)
docker/docker-compose-multi-actor.yml Multi-container topology
docker/seed-configs/ Per-container actor seed configurations