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
- The Reporter's
submit-reporttrigger buildsOffer(VulnerabilityReport)and its outbox delivers it to the Vendor's inbox. - 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 theCASE_MANAGERauthority is established by the actor that will hold it (ADR-0041, CP-09-005). - The Case Actor creates the case, seats itself as
CASE_MANAGER, the Vendor asCASE_OWNERatRM.RECEIVED, and the Reporter asREPORTERatRM.ACCEPTED, initializes the Vendor's default embargo, and seeds both participants' embargo consent as signatories. The embargo is thereforeACTIVEfrom the first ledger entry without a proposal round-trip (EP-04-001). It then sendsAccept(CaseProposal)to the Vendor, followed byCreate(VulnerabilityCase)to the Vendor only (CBT-01-010). ThatCreatecarries the case with its participants inline. The Vendor accepts it, then sends the Reporter its ownCreate(VulnerabilityCase), which carries the same case inline, names the Case Actor asCASE_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). - The Vendor's
validate-reporttrigger sends Report Valid (RV) to the Case Actor. The Case Actor advances the Vendor's participant record toRM.VALIDand commits that status after the act, and the Vendor's replica copies it (RSH-08-006). - Once its replica holds
RM.VALID, the Vendor'sengage-casetrigger sends Report/Case Accepted (RA), and the record advances toRM.ACCEPTEDthe 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
- The demo runner reads the highest ledger index the Vendor's replica holds.
- It waits until the Reporter's replica holds a contiguous ledger up to that index.
- It compares the two replicas: the participant index, the active embargo id, and the ledger tail hash must match.
- It reads the dedicated
case-actorcontainer and asserts that it holds the case, with its Case Actor as aCASE_MANAGERparticipant.
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
- The Reporter's trigger sends one
Add(Note)to the Case Actor. TheAddnames the case as itstargetand carries the note inline as itsobject. - The Case Actor stores the note, attaches it to the case and commits the
Add(Note)as anadd_note_to_caseledger entry. It fans the entry out, and each replica attaches the note when it applies the entry. - The Vendor replies the same way, with the reply note's
inReplyTonaming the question. - 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
- The
notify-fix-readytrigger sends oneAdd(ParticipantStatus)activity per hop of the vendor-path state the Vendor still needs:vfd→Vfd(vendor aware), thenVfd→VFd(fix ready). The hops are separate because the Case State machine admits one event per step. Inrvthe Vendor owns the case and starts atvfd, so both hops are sent; an invited vendor is already atVfdwhen it accepts its invite, and sends only the second. - The Case Actor validates the transition, appends the status to the Vendor's participant record, commits an
add_participant_status_to_participantentry, 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). - The demo does not call
notify-fix-deployed. TheDdimension belongs to a Deployer, and a vendor-only participant stops atVFd.
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
- The Vendor's trigger sends
Add(ParticipantStatus)with the public-path statePxa. - 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 anadd_case_status_to_caseentry carryingEM.EXITED. - The Reporter's trigger sends its own
Add(ParticipantStatus)withPxa.
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).
- 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 commitsclose_casefor the Reporter. It then moves the Reporter toRM.CLOSEDand commits oneadd_participant_status_to_participantentry for each RM transition it writes, in order (CM-23-001). The Reporter is atRM.ACCEPTED, so that is one entry; a participant atRM.VALIDwould get two, forVALID → DEFERREDandDEFERRED → CLOSED(RMB-14-005). - 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'sLeaveovertake the Reporter's. - The Vendor sends
Leave(VulnerabilityCase). The Vendor is theCASE_OWNER, so this is an owner closure (CM-23-002). The Case Actor commitsclose_casefor the Vendor. It expires every embargo invitation still waiting for an answer, with one entry each (CM-23-014); thervrun has none. It then commits the Vendor's status entries. Last, it advances its own participant record toRM.CLOSEDand commits those status entries too (CM-23-005). Its own status entry that reachesRM.CLOSEDis the write boundary: the case is now fully closed (CM-23-013). - 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 itsclose_caseentry and every status entry for its departure, the one that reachesRM.CLOSEDincluded. The Reporter therefore receives none of the Vendor's closure entries. The Vendor receives its ownclose_caseand status entries, but not the Case Actor's own status entry. A replica moves a departing participant only by applying those status entries: theclose_caseentry 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 ownRM.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 |
Related resources
- Run the
rvDemo — the tutorial for this scenario - Running the Multi-Actor Container Demos — selecting and running any scenario
rvscenario narrative — the same case in CVD terms, with its declared causal edges- Message Reference — every activity's canonical form and example
- Formal Protocol Reference — state machine definitions and message types
- Glossary — domain terminology