Project Specifications
Rules that bind this codebase only. They cover deployment choices, such as running the CASE_MANAGER as an actor separate from the case owner, performance and operating limits, how a receiver records what it receives, and core internals. Naming a Python path, library, or class does not by itself make a rule a project rule: who must obey the rule decides its kind.
Behavioral Specifications
The following specifications define Event-Condition-Action (ECA) behavioral requirements with typed preconditions, ordered steps, and postconditions.
Activity Factory Functions
Requirements governing the factory function API for constructing outbound Vultron protocol activities. Factory functions are the sole public construction API for activity objects outside the wire vocabulary layer, replacing direct subclass instantiation to enforce consistent field population and layer separation.
File ID: AF
Purpose and Scope
| ID | Priority | Requirement | Related |
|---|---|---|---|
AF-01-001 |
MUST | Factory functions MUST be the sole public construction API for outbound Vultron protocol activities | |
AF-01-002 |
MUST | Factory functions MUST return plain AS2 base types (e.g. as_Offer, as_Accept, as_Create) rather than Vultron activity subclasses |
|
AF-01-003 |
MUST | Factory function signatures MUST declare explicit, domain-typed parameters for all protocol-significant fields | |
AF-01-005 |
MUST | When a factory parameter represents a domain entity whose fields determine how the wire object is enriched (e.g. a case whose embargo state determines whether activeEmbargo appears in the stub), the factory MUST accept the complete domain object — under ARCH-25-003 the core class is its own wire form, so there is no separate counterpart to project into — and MUST derive the enrichment from that object's own fields. Callers MUST NOT hand a factory a hand-built partial stub (e.g. an as_VulnerabilityCaseStub assembled field-by-field) in place of the object — an incomplete argument risks omitting fields the object would have supplied (e.g. end_time on an embargo event). Factories MUST NOT re-derive AS2 spellings by hand; the field alias a core object inherits (ARCH-12-003) is the only source of the wire spelling, and delivery renders through the port (ARCH-20-001).Enrichment computed in two places drifts. The rule originally required a translator-produced wire object because the domain type and its wire type were different classes; ADR-0099 made them one class, so "the complete wire counterpart" is the domain object itself and the factory may import it — ARCH-22-001 admits vultron/core/models/ and vultron/core/states/ to the wire layer for exactly this. What survives is the invariant the rule always protected: the factory reads the object it was given and never a stub somebody assembled by hand (issue #1300: a bare active_embargo URI reached the wire instead of an embargo event with end_time). |
Depends on: ARCH-22-001 — The allow-list is what lets a factory accept the core object directly. |
Module Structure
Function Naming
| ID | Priority | Requirement | Related |
|---|---|---|---|
AF-03-001 |
MUST | Factory function names MUST be the snake_case equivalent of the corresponding Vultron activity class name |
Error Handling
Internal Vocabulary Classes
Import Boundary Enforcement
TypeAlias Cleanup
Migration
Activity Translator Port
| ID | Priority | Requirement | Related |
|---|---|---|---|
AF-09-001 |
MUST | The correct replacement for from_core() calls in vultron/core/use_cases/ is a driven-adapter port: a Protocol interface defined in vultron/core/ that accepts domain objects and returns wire activities, implemented by an adapter in the wire layer. Core use-cases MUST NOT import from vultron/wire/ directly; they MUST call through the port.from_core() is a wire-layer method. Calling it directly from core use-cases inverts the hexagonal architecture dependency direction: core→wire. The Activity Translator port pattern maintains the correct direction (core defines the port; wire implements it) while providing the same functional capability. See AF-08-002 for migration scope. |
Implements: ARCH-04-001 Refines: AF-08-002 |
AF-09-002 |
MUST | The Activity Translator port MUST be injected into use-cases via dependency injection (constructor parameter or factory argument). Use-cases MUST NOT instantiate the wire-layer translator directly. | Refines: AF-09-001 |
Actor Knowledge Model
Defines the Actor Knowledge Model: a Vultron Actor's knowledge of the world is strictly bounded by the ActivityStreams 2.0 activities it has received. Actors must not assume recipients hold objects that were not explicitly included in prior activities; DataLayer access is private and non-shareable, and all inter-Actor communication occurs exclusively through the AS2 wire protocol.
File ID: AKM
DataLayer Isolation
| ID | Priority | Requirement | Related |
|---|---|---|---|
AKM-01-001 |
MUST NOT | Each Actor's DataLayer is private to that Actor and MUST NOT be accessible to any other Actor. |
Knowledge Boundaries
| ID | Priority | Requirement | Related |
|---|---|---|---|
AKM-02-004 |
MUST | This codebase MUST carry inline every object a recipient cannot be expected to hold already, and MUST use a bare reference only for an object the recipient already has, such as the case it participates in. How this codebase meets AKM-02-002; split from it so AKM-02-002 states only what other participants observe (MS-12-010). This codebase does not yet dereference (AKM-05-003), so a reference to an object its recipient does not hold could not be resolved. Carrying the object inline instead is a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: AKM-02-002 |
AKM-02-005 |
MUST | This codebase's activity factories MUST reduce a full case given as target to its URI and leave a case stub or a URI unchanged. target is the one slot in which this codebase carries a case by reference rather than inline.How this codebase meets AKM-02-003; split from it so AKM-02-003 states only the forms other participants observe in target (MS-12-010). That every other slot carries the case inline follows from this codebase's inline-object shortcut (AKM-02-004, AKM-03-003), not from the protocol. |
Implements: AKM-02-003 |
Outbound Activity Object Integrity
| ID | Priority | Requirement | Related |
|---|---|---|---|
AKM-03-003 |
MUST | This codebase MUST carry the object of every outbound activity as a fully inline typed object, never as a bare URI reference or an as_Link. AKM-03-002 enforces this at delivery.How this codebase meets AKM-03-001 and MV-09-001; split from them so they state only what other participants observe (MS-12-010). A receiver of this codebase does not dereference (AKM-05-003), so it could not resolve a reference to learn the activity's semantic type. Carrying the object inline is a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: AKM-03-001 Implements: MV-09-001 |
AKM-03-002 |
MUST | The outbox handler MUST enforce object integrity at delivery time as a last-resort runtime guard. |
Agentic Readiness
Requirements for API and CLI design that enable reliable interaction by automated agents and AI systems. Covers machine-parseable interfaces, structured response formats, and observable behavior that supports agentic workflows.
File ID: AR
API Discoverability
State and Workflow Transitions
| ID | Priority | Requirement | Related |
|---|---|---|---|
AR-02-001 |
SHOULD | Resources with a status field SHOULD include a next_allowed_actions list identifying valid state transitions from the current state |
|
AR-02-002 |
MUST | All valid states and state transitions for state machine resources MUST be documented | |
AR-02-003 |
MUST | Endpoints with prerequisites MUST return a structured error using the error field value "PreconditionFailedError" when invoked out of sequence |
Depends on: EH-05-001 |
Stable Error Types
| ID | Priority | Requirement | Related |
|---|---|---|---|
AR-03-001 |
MUST | The API MUST use the error field (per error-handling.md EH-05-001) as a stable, machine-parseable error type identifier |
Depends on: EH-05-001 |
Actor Discovery Profile
CVD Action Rules API
| ID | Priority | Requirement | Related |
|---|---|---|---|
AR-07-003 |
MUST | The action rules endpoint MUST be reflected in the OpenAPI schema per AR-01-001 | Depends on: CM-07-001 Depends on: CM-07-002 Depends on: CM-07-003 |
CLI Interface
Architecture
Defines the architectural constraints for the Vultron codebase. The system follows hexagonal architecture (Ports and Adapters): the core domain is isolated from all external systems, which interact through defined ports via thin adapters. Covers layer separation rules, the core object hierarchy, domain model richness, and the fail-fast validation boundary.
File ID: ARCH
Layer Separation
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-01-001 |
MUST NOT | The core/ package (or equivalent domain layer) MUST NOT import from wire/, adapters/, or any external frameworkKeeps domain logic replaceable without touching the wire or transport layer |
|
ARCH-01-004 |
MUST | All ARCH-01-001 violations in vultron/core/ MUST be remediated via dedicated driven ports. Each file with a core→wire import MUST be given a narrow driven port (following the SyncActivityPort or TriggerActivityPort pattern) or folded under an expanded shared port.Leaving core→wire import violations unfixed undermines the hexagonal boundary and prevents clean unit testing of core logic with mock adapters. See tracking issues for the current set of known violations. |
Refines: ARCH-01-001 — ARCH-01-001 is the general constraint; ARCH-01-004 requires that all violations be systematically remediated via driven ports. |
ARCH-01-005 |
MUST NOT | Production adapters (HTTP routes, CLI commands, MCP server, background workers) MUST NOT import from vultron/demo/. Demo-layer modules are not part of the production dependency graph and MUST be treated as test-only code for runtime purposes.The demo layer contains simplified, non-production implementations of actors and data handling. Importing it in production adapters silently pulls in demo behaviour and dummy data, and couples production code to a layer with no stability guarantees. |
Refines: ARCH-01-001 |
ARCH-01-006 |
MUST NOT | Demo-layer modules (vultron/demo/**) MUST NOT import from the legacy simulator package (vultron/bt/**). The sole permitted exception is the unified demo CLI aggregator (vultron/demo/cli.py, mandated by DC-01-001), which surfaces the standalone vultron/bt/-based behaviour-tree demos (pacman, robot, cvd) as a sub-group. Demo logic modules (vultron/demo/scenario/, vultron/demo/exchange/, vultron/demo/helpers/, vultron/demo/fuzzer/) MUST remain free of any vultron/bt/ import.vultron/demo/ demonstrates the production protocol; vultron/bt/ is the frozen legacy custom-engine simulator slated for retirement (see notes/codebase-structure.md "Module Boundary"). Coupling demo logic to the legacy simulator undermines the decoupling vultron/demo/fuzzer/ was created to achieve. cli.py is exempt as a thin aggregator entry point — the single documented bridge to the bt-tree demos. Derives from ADR-0009 (hexagonal). |
Refines: ARCH-01-001 |
SemanticIntent Enum
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-02-001 |
MUST | The MessageSemantics enum (SemanticIntent) MUST be defined in the domain layer, not in the wire layer |
Semantic Extractor
Driven Adapter Injection
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-04-002 |
MUST | SyncActivityPort MUST be defined as a typing.Protocol in vultron/core/ports/sync_activity.py with two fire-and-forget methods: send_reject_log_entry(entry, tail_hash, actor_id, to) and send_announce_log_entry(entry, actor_id, to). send_announce_log_entry reports whether a row was queued (bool; False when an identical row was already pending, SYNC-15-012); callers MAY ignore the result, and no other value crosses the port back into core.The incremental fix for ARCH-01-001 violations in received/sync.py and triggers/sync.py. The "baton-pass" pattern: core hands domain objects to the port; the adapter handles wire conversion unidirectionally. The one bit that comes back lets a replay count only rows actually queued as sent (SYNC-15-011) without core reading the outbox itself (DL-06-001); see ADR-0112. |
Refines: ARCH-04-001 — SyncActivityPort is the specific driven port for sync outbound activities Implements: ARCH-01-001 — Eliminates wire imports from core sync use cases |
ARCH-04-003 |
MUST | The concrete adapter (SyncActivityAdapter) MUST be implemented in vultron/adapters/driven/sync_activity_adapter.py and own the full domain → wire → persist → outbox pipeline.Concentrating the full domain → wire → persist → outbox pipeline in the adapter keeps wire concerns out of core and satisfies the baton-pass pattern. |
Refines: ARCH-04-002 |
ARCH-04-004 |
MUST | Core use cases and BT nodes MUST call only the SyncActivityPort methods.Calling only port methods enforces the hexagonal boundary and enables testing core logic with mock adapters. |
Refines: ARCH-04-002 |
ARCH-04-005 |
MUST NOT | Core use cases and BT nodes MUST NOT import wire types (CaseLedgerEntry, WireCaseLedgerEntry) or wire factory functions (announce_log_entry_activity, reject_log_entry_activity).Wire type imports in core violate the hexagonal boundary; all wire concerns are handled exclusively by the SyncActivityAdapter. |
Refines: ARCH-04-002 |
Connector Plugins
Core Object Hierarchy
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-12-001 |
MUST NOT | A wire-branch class MUST NOT inherit from a core class. as_Base stands on pydantic.BaseModel directly, so the wire branch inherits no core field, configuration, or registration hook. A domain type's as_* name is an alias of its core class (ARCH-25-003), not a subclass of it.The shared root made every wire class inherit core configuration, which forced the _is_core_branch sentinel (#2416) so wire types would not self-register in the core registries, and forced the core root to stay lenient so the wire branch could parse loose inbound data (ARCH-12-002 as formerly written). Under one object model there is nothing for the two roots to share: the wire base describes AS2 vocabulary the core does not model, and everything the core does model is the core class itself. |
|
ARCH-12-002 |
MUST | Core MUST have exactly two root classes. CoreRecord carries identity only (id_, type_, name) and is the base for internal records that are never serialised as AS2 (dead-letter entries, offer and replication bookkeeping). CoreObject(CoreRecord) is the one AS2 object root: it declares every AS2 Object field, including those no Vultron type uses yet, which stay declared (ARCH-25-008). Every core model MUST descend from one of the two.The middle level ( VultronBase beneath VultronObject) existed so that one ancestor could serve a strict core branch and a lenient wire branch. With the wire base on its own root, the only distinction left is whether a type has an AS2 shape at all, and the two roots state exactly that: the rendering port accepts a CoreObject and refuses a bare CoreRecord (ARCH-20-003). |
|
ARCH-12-003 |
MUST | Core AS2 types (CoreObject subclasses) MUST carry the AS2 field spelling as their own serialization form, through the alias_generator=to_camel they inherit from CoreObject (ARCH-25-002); a by_alias dump of a core object is its AS2 form. Core types MUST resolve extra="forbid" through their model_config, so that an unrecognised key raises rather than being silently discarded. Core types MUST NOT register in the wire VOCABULARY, and MUST NOT store @context as a serialised field: CoreObject.context_ stays exclude=True, and @context is supplied once by CoreObject's by_alias serializer (VM-10-001).One spelling on one class is what makes a core dump the wire form, which is what lets the rendering port be a plain dump (ARCH-20-002) instead of a second translation path that drifts. The #2232 hazard this requirement once cited against the generator was a key matching no field and being silently discarded, not a key having two spellings: a reader that accepts several spellings for the same field on the same class, as ParticipantStatus.rm does with AliasChoices("rmState", "rm"), never decides what the data means. ARCH-25-007 is where that argument attaches (ADR-0099, as amended in #3888 and #3900), and extra="forbid" is its enforcement on the in-process and stored side: Pydantic v2 defaults to extra="ignore", so without it a wire-shaped payload handed to a core type silently loses every key it does not recognise, which is how a participant's RM ladder was reset without a trace. Forbidding unknown keys is strictly stronger than rejecting particular spellings; it also catches typos, renamed fields, and stale keys from an older schema. Inbound data reaches a core class only through the parse edge, which partitions unknown keys off first — refusing a near miss, setting aside and reporting a foreign key (MV-11) — so extra="forbid" is never the rule that decides an inbound peer's extension property (MV-11-004). |
Depends on: ARCH-20-001 — ARCH-20 keeps the port as the only renderer, so core code does not dump its own objects with by_alias=True even though that dump is now the wire form.Depends on: ARCH-23-005 — ARCH-23-005 supplies the round-trip cleanups (computed-field stripping, wire-name keys) without which extra="forbid" rejects the project's own serialised output. |
ARCH-12-004 |
MUST NOT | The wire branch MUST NOT carry domain validators. Wire-branch overrides exist solely for AS2 serialization shape (field aliases, @context, lenient Any | None types).CORE_VOCABULARY is the DataLayer primary reconstruction path (used by _from_row() in SqliteDataLayer); every entry must be a CoreObject subclass. CORE_TYPE_MAP is the fallback for find_in_vocabulary() in the wire registry layer, catching core types that are not CoreObject subclasses (e.g. VultronOfferRecord, VultronPendingCaseInbox). These types extend CoreRecord directly and carry Vultron-specific domain state that has no AS2 shape. Contaminating CORE_TYPE_MAP with wire types (issue #2416) would silently allow wire-shaped objects to be reconstructed via find_in_vocabulary(), bypassing the CORE_VOCABULARY primary path and contradicting ARCH-12-003. |
|
ARCH-12-008 |
MUST | Wire-branch types MUST register in VOCABULARY.The VOCABULARY registry is the wire layer's type-dispatch table; omitting a wire type prevents correct AS2 vocabulary lookup and object reconstruction. |
Refines: ARCH-12-004 |
ARCH-12-009 |
MUST | CoreObject subclasses MUST register in CORE_VOCABULARY as the DataLayer primary reconstruction path.CORE_VOCABULARY is used by _from_row() in SqliteDataLayer; every entry must be a CoreObject subclass to guarantee correct domain-object reconstruction from stored rows. |
Refines: ARCH-12-004 |
ARCH-12-010 |
MUST | Core types that extend CoreRecord but not CoreObject MUST register in CORE_TYPE_MAP so that core types remain discoverable by type string. Registration in CORE_TYPE_MAP MUST NOT be relied on as the resolution path for a wire-branch caller (VM-06-008).CORE_TYPE_MAP catches core records (e.g. VultronOfferRecord, VultronPendingCaseInbox) that carry domain state with no AS2 shape and so are not CoreObjects. Those types need a name-keyed lookup for core-layer and persistence callers. They do not need to answer a wire-branch caller's type lookup: a hit there is a coincidence of naming, not a wire counterpart. |
Refines: ARCH-12-004 |
ARCH-12-011 |
MUST NOT | Wire-branch types MUST NOT register in either core registry (CORE_VOCABULARY or CORE_TYPE_MAP).Contaminating either core registry with wire types would allow wire-shaped objects to be reconstructed via find_in_vocabulary(), bypassing the CORE_VOCABULARY primary path and contradicting ARCH-12-003. |
Refines: ARCH-12-004 |
ARCH-12-006 |
MUST | Actor inbox and outbox fields MUST be typed str on CoreActor and its subclasses, carrying only the URI of the inbox or outbox endpoint, and MUST never be None or empty: an absent, None or blank endpoint MUST be derived from the actor's id_. AS2 OrderedCollection wrapping is a wire-only serialization concern and MUST NOT appear in core-branch actor types.Domain logic only ever needs to route to the inbox URI; the full collection shape is an AS2 protocol concern, not a domain concern. ActivityPub requires every actor to have both endpoints, so an actor without one is not a valid actor, and deriving the endpoint from the id keeps the field total without a None check at every route. |
|
ARCH-12-007 |
MUST | All core domain objects that represent protocol-significant entities (cases, reports, participants, actors, log entries, embargo policies, etc.) MUST inherit from CoreObject (or CoreRecord when the object has no AS2 shape and CORE_VOCABULARY registration is not needed). Objects that exist solely as local configuration, transient dispatch envelopes, or internal processing records that are never shared across the wire MAY remain plain BaseModel subclasses; such classes MUST include a docstring explaining the rationale for not inheriting from the core object hierarchy.Consistent use of the core object hierarchy ensures that all protocol-significant domain objects share aligned field names, are rendered to AS2 form by the rendering port (ARCH-20-001) without per-type projection code, and are discoverable via CORE_VOCABULARY. Intentional BaseModel classes are the exception, not the default; requiring a documented rationale prevents accidental hierarchy bypass as the codebase evolves. |
Domain Model Richness Constraint
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-09-002 |
MUST | Actor domain models (CoreActor and its concrete subclasses VultronPerson, VultronOrganization, VultronService, VultronApplication, VultronGroup) MUST be defined in vultron/core/models/. The wire layer (vultron/wire/as2/) MUST NOT define a second actor class for them; its as_Vultron* actor names alias the core classes (ARCH-25-003).Defining actor domain models in core enforces the hexagonal boundary. The re-export pattern (where wire re-exported core actors directly) was a transitional workaround introduced when field-type conflicts blocked diamond inheritance. Under one object model (ADR-0099) the re-export is the design rather than a workaround: an actor has one class, and its AS2 form is that class's own serialization. |
Refines: ARCH-09-001 Depends on: ARCH-12-001 — ARCH-12-001 forbids a wire class from inheriting core; the as_Vultron* actor names are aliases of the core classes, not subclasses of them. |
Fail-Fast at Domain Operation Boundaries
Governs relational invariants enforced at runtime entry points — use-case execute(), BT node update(), and helper functions that require non-None resolved values. Extends the construction-time fail-fast rule to cover checks that can only be performed at the point of invocation, not at object construction.
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-15-001 |
MUST | A BT node's update() MUST return Status.FAILURE when a required blackboard key is absent or resolves to None. It MUST NOT return Status.SUCCESS in this case, as doing so misleads the enclosing BT sequence and silently drops protocol behavior (e.g., ledger entries not written). Status.SUCCESS on a missing required input causes the BT sequence to continue as if the node succeeded, masking the failure and producing ledger gaps, dropped broadcasts, and mis-routed activities. See notes/domain-validation.md and ADR-0032. |
Refines: ARCH-10-001 — Extends construction-time fail-fast to runtime BT node entry points. |
ARCH-15-002 |
MUST | A helper function that requires a non-None ID MUST check the result and raise VultronValidationError immediately. The strict guarantee lives in the caller or the helper itself — never deferred silently to downstream code. Returning None from a helper that conceptually requires a valid ID moves the error from the point of failure to the point of use, making stack traces and logs point to the wrong location. See notes/domain-validation.md and ADR-0032. |
Refines: ARCH-10-001 — Extends construction-time fail-fast to helper function entry points. |
Wire Vocabulary Object Naming Convention
DataLayer Scope Contract
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-13-003 |
MUST | The actor identity a DataLayer is opened under MUST be the actor's canonical URI — the value of actor.id_ — and MUST NOT be a short ID, a URL path segment, or any other derived identifier. Where a request carries a path segment, it MUST be resolved to the canonical URI before the store is opened.Two spellings of one actor must not name two stores. This entry survives ADR-0073 — it is the reason get_actor_dl resolves the segment by computation before opening anything — but its wording did not: it referred to constructing an ActorScopedDataLayer and justified itself by record_outbox_item writing rows keyed by the string it received, and both of those are gone. The original failure mode was BUG-2026040901, where a queue written under the canonical URI was read under the short id and the activities vanished. That specific mechanism is now unreachable — queues are not keyed by an id string — but the requirement still binds, because the canonical URI is what selects the store, so a short id opens a different and empty one. |
Refines: DL-07-006 |
App State Isolation
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-16-001 |
MUST NOT | create_app() (or any equivalent application factory) MUST NOT mutate module-level singletons, global registries, or process-wide shared state. All per-application state MUST be scoped to the returned app instance or its lifespan context.Mutating module-level singletons in create_app() makes test isolation impossible: the second call to create_app() in the same process inherits mutations from the first. This applies to vocabulary registries, DataLayer factories, BT bridges, actor caches, and any other mutable global. Discovered as a demo-layer issue (DEMOMA-01-004) but applies equally to all application factories. |
Implements: ARCH-04-001 |
ASGIEmitter Base URL
SUPERSEDED by ADR-0042 / outbox.yaml OX-12. The ASGIEmitter in-process delivery shortcut has been retired in favour of uniform HTTP delivery for all inter-actor communication. These requirements are retained for historical traceability only and no longer describe the delivery architecture; do not implement against them.
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-17-001 |
MUST | The ASGIEmitter base URL MUST contain only the scheme and netloc components (e.g., https://example.org). It MUST NOT include a path prefix, trailing slash, or mount-point segment. The emitter strips the mount prefix from request paths before constructing outbound URLs; a base URL that includes a prefix causes double-application.A base URL of https://example.org/api combined with a request path /api/actors/… produces https://example.org/api/api/actors/…. Root cause of bugs #557 and #558. |
|
ARCH-17-002 |
MUST | ASGIEmitter MUST use a contextvars.ContextVar[int] reentrancy guard, not a threading.Lock or boolean flag, to detect and prevent re-entrant emission (an emitter call triggered by a handler that was itself invoked by an earlier emission). Re-entrant emission MUST raise EmitterReentrancyError.A threading.Lock guard would deadlock when the re-entrant call happens on the same thread (common in async/ASGI contexts). A ContextVar is task-local and correctly tracks reentrancy per async task without thread-lock semantics. |
Refines: ARCH-17-001 |
Core-to-Wire Rendering Port
Governs how core domain objects are rendered into wire-shaped (AS2 camelCase) JSON. Core legitimately needs a wire representation in one place — the CaseLedgerEntry.payloadSnapshot, which CLP-07-011 requires to be receiver-reconstitutable AS2 — and this group makes a driven port the single seam that produces it. Under one object model (ADR-0099) the spelling lives on the core class (ARCH-25-002) and a dimension serializes to a bare state value (ARCH-25-005), so the port's adapter is a plain by_alias dump of the core object; what the group still guards is that core code reaches that dump only through the port.
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-20-002 |
MUST | The rendering port's adapter MUST produce its output as the core object's own model_dump(by_alias=True, exclude_none=True, mode="json", serialize_as_any=True). It MUST NOT resolve a wire counterpart (through WIRE_TYPE_MAP, a pairing registry, or a from_core() projection) and MUST NOT reimplement field-name translation.Under one object model the core object is its own wire form (ARCH-25-001), so a counterpart lookup has nothing to find and any second translation path could only drift from the class's own spelling, the defect ADR-0063 removes. serialize_as_any=True renders nested values by runtime type, as every other AS2 path does; without it an inline wire object holding an undeclared value type (an IRI inbox) is a serializer warning, not a rendering (#3930). |
Refines: ARCH-20-001 |
ARCH-20-003 |
MUST | When the supplied object has no AS2 shape, that is, when it is not a CoreObject (a bare CoreRecord such as an offer or dead-letter record, or any other model), the rendering port MUST raise VultronValidationError. It MUST NOT fall back to a core-shaped model_dump, return None, or emit a partially-rendered object. The absence of a separate wire class is not a failure.A silently core-shaped payload snapshot is unreconstitutable by receivers (CLP-07-011) and is indistinguishable from a correct one at the call site. Failing loudly is required by ARCH-15-002. The CoreObject gate is what stops a bookkeeping record such as VultronOfferRecord from leaking into a delivered payload now that a missing counterpart no longer refuses it. |
Refines: ARCH-15-002 |
ARCH-20-004 |
MUST | The rendering port MUST be injected into behavior-tree execution using the established mechanism — a constructor parameter on BTBridge published to the blackboard under a named key — in the same manner as sync_port. BT nodes and use-case helpers MUST NOT import an adapter directly or instantiate a default renderer.Consistency with SyncActivityPort keeps one injection idiom for driven ports. An import-time default would reintroduce the core→wire import the port exists to prevent (ARCH-01-001). |
Depends on: ARCH-20-001 |
ARCH-20-005 |
MUST | Core-branch types MUST NOT declare a to_json(), to_wire(), or similarly-named method that emits wire-shaped output. Rendering is the port's responsibility, not a method on the domain object.CoreActor.to_json() dumped with by_alias=True and had no callers; such a method is an always-available bypass of the port seam and will be picked up by the next agent that needs camelCase. |
Refines: ARCH-20-001 |
ARCH-20-006 |
MUST | Driving adapters that serve an AS2 document over HTTP (an application/activity+json response, e.g. GET /actors/{id}) MUST serialise it from an AS2 class: a CoreObject subclass, which is its own wire form (ARCH-25-003), or an as_Base wire vocabulary class. They MUST NOT synthesise an AS2 document by dumping any other model with by_alias=True, a bare CoreRecord included.A domain type has one class, so the AS2 form of a core actor or case is that class's own by_alias dump; there is no separate as_* class to route through. What still varies is whether a model has an AS2 shape at all. A CoreObject inherits the alias_generator and the @context serializer, and test_promoted_core_classes_are_exactly_as2_representable holds every subclass to a complete set of AS2 spellings. A bare CoreRecord such as VultronOfferRecord inherits neither, so dumping it by alias emits snake_case bookkeeping keys with no @context under an AS2 content type, which a peer cannot read. This is the HTTP edge's counterpart of the rendering port's CoreObject gate (ARCH-20-003). |
Refines: ARCH-20-001 Refines: ARCH-20-003 |
ARCH-20-007 |
MUST NOT | Core code MUST NOT normalise a wire object into its core counterpart by dumping it with by_alias=True and revalidating through the core class ("dump-and-revalidate").Dump-and-revalidate normalises only as far as the core class happens to accept every key a wire dump emits. ARCH-12-003 has a core class read its own AS2 spelling, not an arbitrary wire object's: under extra="forbid" a wire key with no core field raises, and without it the key is silently discarded. The received-status filter once relied on the since-retired _migrate_flat_fields shim (SDO-03-003). |
Depends on: ARCH-20-001 Refines: SDO-03-005 |
ARCH-20-008 |
MUST | Core code holding an inbound nested object MUST treat a core-branch instance as already canonical — under ARCH-25-003 the wire class of a domain type is the core class, so there is no wire→core projection step — and MUST rebuild an object from a snapshot dict only through project_wire_snapshot_to_core (vultron/core/models/_helpers.py). Core MUST NOT duck-type a projection capability on the object it holds: getattr(obj, "to_core", None) and equivalents are prohibited.No class core can hold carries a to_core() any more, so a duck-typed lookup always answers None and the branch behind it is dead code that reads as a live fallback — three such sites survived the migration until #3840 removed them. The WireParsePort that ADR-0082 designed to own this step was rejected by ADR-0099; rehydrate() owns ID-to-object materialisation (VM-06-007) and the snapshot projection helper owns the dict case, so nothing is left for a port to do. Refusing the idiom also keeps ARCH-20-007's prohibition on dump-and-revalidate from being reached around. |
Refines: ARCH-20-007 Depends on: VM-06-007 — ID-to-object materialisation is rehydrate()'s job, so core never needs a parse port. |
ARCH-20-009 |
MUST | When a received activity carries a nested object only by reference — a bare IRI or a stub — core MUST obtain the object from the DataLayer port (DL-05-001), either as rehydrate() resolved it at ingress (VM-06-007) or by reading it back. It MUST NOT be synthesised in core by dump-and-revalidate, and MUST NOT be replaced by a fabricated placeholder.The DataLayer port returns core objects that already passed the validation boundary, so re-deriving one in core is both unnecessary and the prohibited idiom. This requirement was written against an absent to_core() override and then an absent pairing-registry entry as the signal that "no translation exists"; under ADR-0099 there is no translation at all, so the only question left is where a missing object comes from, and the answer is the store. |
Refines: ARCH-20-007 Depends on: VM-06-007 Depends on: DL-05-001 |
Post-Construction Mutation Safety
Requirements governing validation of core domain objects after construction. Pydantic v2 validates at construction only, so attribute assignment and in-place collection mutation bypassed every type guarantee, allowing a wire-shaped object to occupy a core-typed field and be silently misread as absent (#2232, #2264). Codified by ADR-0064. Source: CONCERN-2261.
Wire→Core Import Boundary Enforcement
Core↔Wire Vocabulary and Round-Trip Invariants
| ID | Priority | Requirement | Related |
|---|---|---|---|
ARCH-23-002 |
MUST | Wire vocabulary registration keys in VOCABULARY MUST be distinct from core vocabulary keys in CORE_VOCABULARY so that no wire class shadows a core type.Fifteen wire classes shadowed a CORE_VOCABULARY entry, which is what made the _NORMALIZE_WIRE_TO_CORE persistence-boundary workaround necessary (issue #2232, ADR-0062). Under ADR-0099 both the shadow classes and the workaround are gone; disjoint keys preserve that state and prevent a future wire class from accidentally reintroducing the collision. |
Depends on: VM-01-005 — Import-time registration is why the verification must force population before comparing the registries. |
ARCH-23-003 |
MUST NOT | Serialized AS2 output MUST NOT change as a result of a vocabulary-key change. The wire "type" value emitted on the wire is independent of the registry key under which a wire class is registered.The registry key is an internal lookup concern; the type field value is an external protocol commitment that receivers match on. Conflating them is what made VM-01-004 read as though the key and the wire value must be the same string, when the code already derives the key from the class name and diverges for the five actor types. |
|
ARCH-23-004 |
MUST | The set of AS2 reference-typed fields MUST be derived from the wire classes' type annotations. It MUST NOT be restated as a hand-maintained collection, and per-field reference collapsing MUST NOT be open-coded in individual translation paths. This knowledge was stated three times: by the as_ObjectRef annotations (the truth), by _AS_OBJECT_REF_FIELDS in vultron/adapters/driven/db_record.py whose own comment described it as a restatement of those annotations, and by hand-written _scalar_ref_id_or_value(...) calls inside individual projection methods. Two of the three could drift from the first silently. |
|
ARCH-23-005 |
MUST | A serialize-then-validate round trip on a core object MUST reproduce that object. Serialization MAY emit computed fields (VulnerabilityCase.activeParticipants, on the AS2 by_alias path only because CM-31-014 forbids storing it; ADR-0056's embargo_adherence was retired by ADR-0122); validation MUST discard a supplied computed-field value that equals the value derived from the object's own state, and MUST refuse one that contradicts it rather than silently discarding it (#3547). Serialization MUST emit wire-facing key names (id, type, @context) rather than Python field names (id_, type_, context_). This applies to the persistence record round trip as well as to translation.*A contradicted computed value cannot come from an honest producer: the value is derived, so every dump agrees with itself. Refusing it closes the last silent-drop carve-out in a boundary whose contract is that an unrecognised key is rejected loudly, and a derived value that gates disclosure (as embargo_adherence did) must fail closed when a peer contradicts it. The objection recorded during #2940 triage — that as_ParticipantStatus carried an independent settable wire field — was retired when that class was aliased onto core (ARCH-25-003). |
|
extra="forbid" (ARCH-12-003) is only viable if the project can round-trip its own output. Measured on the unit suite, the two blockers were embargo_adherence — a @computed_field per ADR-0056 that appears in model_dump() output but is not settable — with 1096 rejections, and id_ with 110. Neither was a wire-spelling problem; both were the project failing to re-read what it had just written. Record.from_obj() calls model_dump(mode="json", serialize_as_any=True) with no by_alias, so persisted rows are keyed id_/type_, which read back cleanly only because populate_by_name=True accepts the field-name spelling.* |
|||
ARCH-23-006 |
MUST | Core classes used in wire-branch field annotations for protocol-significant message slots MUST be exactly AS2-representable: every field they carry MUST have a valid AS2 property spelling via the alias_generator inherited from CoreObject (ARCH-12-003), and they MUST NOT carry a field that cannot go on the wire.ARCH-25-003 deletes the paired as_* classes (ADR-0099); message slots now name core classes directly (object_: VulnerabilityReport, target: VulnerabilityCase), inverting the old prohibition. The constraint becomes a guarantee: a class in a message slot must be exactly AS2-representable. The enabling prerequisite — VultronValidationError inheriting ValueError so a guard inside a union fails that branch cleanly — is met; test_core_guard_inside_wire_union_fails_the_branch holds it. |
|
ARCH-23-007 |
MUST | A derived actor endpoint collection — an actor's inbox or outbox OrderedCollection built from a bare URI or from the actor's id — MUST carry no published or updated timestamp. A timestamp that arrives on an inbound endpoint collection MUST be carried as received.A derived endpoint is an address, not an object this process authored, so it has no creation time to state (ADR-0103 stamps what this process authors and leaves an inbound omission absent; a derived address is neither). Stamping it from the clock made a stored actor read back unequal to itself whenever a wall-clock second passed between write and read, because the core actor keeps only the URI (ARCH-12-006) and each read minted a new stamp (#3732). |
Refines: ARCH-23-003 Depends on: ARCH-12-006 |
One Object Model; AS2 Is a Serialization of It
The decision-level rules of the one-object model: the core classes are the object model, and the ActivityStreams 2.0 (AS2) wire format is one of two ways those same classes are written out. Each requirement here states one consequence of that decision in full, so a reader needs no decision record to know the rule. Finer-grained requirements in ARCH-12, ARCH-20, ARCH-22, ARCH-23, SDO-01, MV-11 and VM-06 refine these; where one does, the statement it refines names it.
Behavior Tree Integration
Requirements for integrating py_trees behavior trees into Vultron handler functions. Covers the BT execution model, state management via the DataLayer, handler integration patterns, actor isolation, concurrency constraints, and failure diagnosis. Complex protocol workflows such as report validation, case creation, and embargo management must be implemented as BT nodes or subtrees.
File ID: BT
BT Execution Model
BT Library
State Management
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-03-002 |
MUST NOT | BTs MUST NOT maintain separate blackboard persistence | |
BT-03-003 |
MAY | BT blackboard MAY cache DataLayer state during execution |
Handler Integration
BT Bridge Layer
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-05-001 |
SHOULD | System SHOULD provide BT execution bridge for handler-to-BT invocation | |
BT-05-002 |
SHOULD | Bridge SHOULD set up py_trees context with DataLayer access | |
BT-05-003 |
SHOULD | Bridge SHOULD populate blackboard with activity and actor state | |
BT-05-004 |
SHOULD | Bridge SHOULD execute tree and return execution result | |
BT-05-005 |
MUST | A behavior tree's blackboard datalayer MUST be the store of its blackboard actor_id. The bridge MUST reconcile the two rather than accepting whatever store the caller injected.BT-05-002 and BT-05-003 put the store and the executing actor on the blackboard as two independent facts, so they could disagree. Under per-actor storage they are one fact: a store is always some actor's own, so the executing actor's identity determines which store the tree operates on. The delegated-emit pattern is where the divergence bites — a trigger emitting on the CaseActor's behalf runs with actor_id set to the CaseActor while the injected DataLayer belongs to the requesting actor, so the activity is created in one store and queued in the other's outbox: the CaseActor never delivers it, and its outbox names an activity its own store does not hold. Reconciling once in the bridge makes every node write correct by construction rather than correct only when the caller remembered to inject a matching DataLayer. This was chosen over fixing only the trigger seam, for the same reason isolation must not depend on every query remembering a filter. There is no foreign-authority exception: an actor_id the store's authority does not host has no store to reconcile against, and the bridge MUST raise rather than let the tree run in the requester's store. A participant whose container does not host the CASE_MANAGER reaches it by message (CM-24-004, ADR-0109); an earlier exception that kept the requester's store for a remote CaseActor existed only to let the case owner's container emit in the CaseActor's name, and it made the CaseActor learn of its own emission by a cc: copy (#2996). |
Implements: DL-07-001 Refines: BT-05-002 |
BT-05-006 |
MUST | The executing actor of a role-gated tree MUST hold the gating role, and the tree's store MUST be that actor's. Where a tree is gated on a role held in the case, the role holder, the receiving actor and the store owner MUST be one actor. These are three separately-specified identities, and letting any two drift produces a silent skip rather than an error: the gate evaluates against an actor that holds no role, returns SUCCESS-by-skip, and nothing is written. This is the invariant that made the delegated-emit defect class possible in the first place. Stated as a MUST rather than a note because production satisfying it today is not the same as it being required — without one, a future BT node can re-separate the three identities and the failure mode is again a silent skip. A static architecture ratchet was considered and rejected: role holding is data-dependent, so a check could only approximate it and would be noisy. |
Refines: BT-05-005 Refines: CLP-09-001 |
Workflow-Specific Trees
DataLayer Integration
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-07-001 |
MUST | BT nodes MUST interact with DataLayer via Protocol interface | |
BT-07-002 |
MUST | BT nodes MUST use type-safe DataLayer operations |
Command-Line Execution
Actor Isolation
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-09-002 |
MUST NOT | Actor blackboards MUST NOT share state |
VFD/PXA State Machine Usage
Concurrency
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-11-002 |
MUST NOT | Sequential processing MUST NOT block HTTP response (BackgroundTasks) | |
BT-11-004 |
MUST | BTBridge MUST acquire a threading.RLock (re-entrant lock), not a plain threading.Lock, when serializing BT execution. Any code path in which execute_with_setup() calls back into BTBridge (directly or transitively) will deadlock with a plain Lock because the same thread attempts to acquire a non-reentrant lock it already holds.BTBridge.execute_with_setup() invokes user-supplied setup and teardown hooks. In integration and demo scenarios these hooks may trigger secondary BT executions via the same bridge instance. A plain Lock would deadlock on the second acquisition from the same thread. An RLock allows re-entry by the owning thread while still blocking other threads. |
Refines: BT-11-001 |
BT Failure Diagnosis
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-13-001 |
MUST | After any BT execution that returns FAILURE, code MUST retrieve the failure reason via BTBridge.get_failure_reason(tree) (a walk down the chain of failed nodes to the failing leaf, taking the last failed child of each composite). Code MUST NOT log or inspect result.feedback_message or bt.root.feedback_message for diagnostic purposes: a py_trees Sequence root's feedback_message is always "" when a child fails, regardless of what the failing child set. The one exception is a node that raised: it never set a status, so get_failure_reason finds no failing leaf, and result.feedback_message then carries the bridge's composed exception message.py_trees sets feedback_message on the failing child node, not on the parent Sequence. Logging the root message produces an empty string that is indistinguishable from "no error" in logs, making failures completely silent. get_failure_reason() walks to the actual failing leaf and returns its message. |
|
BT-13-002 |
MUST | The BTBridge.get_failure_reason() pattern MUST be applied consistently across all BT execution sites, not only for a single BT class or protocol flow. Any place in the codebase that logs a BT result MUST use this helper. |
Refines: BT-13-001 — Scope is all BT execution sites, not just sync |
Peer Broadcast Failure Semantics
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-14-001 |
MUST | Any BT node or subtree that performs protocol-visible peer broadcast fan-out MUST return FAILURE when broadcast preparation or outbox enqueueing fails. A guaranteed SUCCESS fallback that masks delivery failure MUST_NOT be used. Silent success on failed fan-out causes undetected peer state divergence and hides correctness faults from parent tree control flow. |
Refines: BT-06-001 — Broadcast fan-out is protocol-significant behavior Refines: BT-04-004 — Delivery failures must propagate to handler error paths |
Trigger-Side Use-Case Contract
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-15-001 |
MUST | Trigger-side execute() methods MUST delegate state-machine transitions (RM, EM, CS) to BT leaf nodes via BTBridge, following the same pattern as received-side use cases. State machine transitions are protocol-significant behavior (BT-06-001). Inline transitions in execute() bypass BT audit, logging, and failure introspection via get_failure_reason(). The historical asymmetry between trigger-side and received-side use cases arose from the now-retired "simple CRUD" guidance. |
Refines: BT-06-001 — SM transitions are protocol-significant behavior with no skip threshold Refines: BT-06-006 — SM transitions performed outside a BT violate BT-06-006 |
BT-15-002 |
MUST NOT | Trigger-side execute() methods MUST NOT call domain lifecycle methods (e.g., EmbargoLifecycle, direct ParticipantStatus writes of rm/em state) outside a BT execution context. Direct lifecycle calls in execute() make protocol transitions invisible to BT-level audit and observability tooling. The BTBridge pattern (instantiate BT → execute_with_setup() → introspect result) is the required contract for all use-case execute() methods that perform state transitions. |
Refines: BT-06-006 |
BT-15-003 |
MUST | A BT-backed trigger whose request names a case MUST run the case-wide clock decisions (the embargo end-time check, EMB-13-003) as the leading, CASE_MANAGER-gated step of its own one tree, the same clock-decision stage a received tree runs (CLP-10-025), and never as a separate tree run before it. The terminate-embargo trigger is the one exception: its own tree takes the end-time path once the end time has passed and answers for it. An embargo trigger also takes the overdue-invitation sweep (CM-28-015) in that stage, after the case-wide decisions; terminate-embargo takes the sweep alone. A trigger that runs no tree changes no case state and takes no clock decision. An overdue embargo is a fact about the case, whatever the entry point. Without the check, a trigger such as accepting a pending revision would extend an embargo whose end time has passed. A dispatcher pre-dispatch hook ran the check as a second tree; composing it into the trigger's tree keeps one tree per request (ISSUE-4641, ADR-0135). |
Refines: BT-15-001 Refines: CLP-10-025 |
Role-Gated BT Subtrees and Blackboard Hygiene
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-17-001 |
MUST | Any BT subtree whose semantics are restricted to the CVDRole.CASE_MANAGER actor MUST be wrapped in a role-guard composite using CheckIsCaseManagerNode at tree-composition time, not enforced via application-layer guards outside the tree.Received-side use cases execute BTs with the receiving actor's identity. Without a role guard inside the tree, a CASE_MANAGER-only node fires for every actor that receives the triggering activity — including participants who should be silent recipients. Issue #1030 observed AutoCloseBranchNode firing on finder-actor because no role guard existed inside the tree; combined with a module-level idempotency set, the phantom fire claimed the slot and prevented the legitimate CASE_MANAGER fire. The canonical role-guard composite is a Selector with a guarded Sequence (CheckIsCaseManagerNode + action node) and a Success("Skipped") fallback. |
Refines: BT-06-001 — CASE_MANAGER-only behavior is protocol-significant; it must live inside the tree with an explicit guard |
BT-17-002 |
MUST | A module-level idempotency set (a set[str] used to track "already fired" state for a BT node) MUST be paired with a role guard (BT-17-001) that prevents any actor other than the intended role from writing to the set.Module-level sets are per-process, not per-container. In a Docker deployment, each actor process has its own set; a phantom fire in process A does not affect process B. However, within a single process, two phantom fires (the wrong actor fires before the correct one) can race: the phantom claims the set slot first and silently blocks the legitimate fire. Pairing the set with a role guard ensures only the correct actor can claim the slot. |
Refines: BT-17-001 |
BT-17-003 |
MUST | When a BT node takes a no-op path (e.g., an empty recipient list or a guard condition that is not met), it MUST explicitly write None to any output blackboard key it would normally set before returning FAILURE or SUCCESS. It MUST NOT leave a stale value from a previous execution in the blackboard.py_trees.blackboard.Blackboard.storage is process-global. execute_with_setup resets only the keys on its managed_keys allowlist on exit, so a node-written output key survives the execution unless it is explicitly listed there. A consumer node that reads such a key may receive the value from a prior execution when the producer short-circuits without writing None — silent state contamination across otherwise independent BT executions (issue #834). |
Refines: BT-06-004 — Blackboard cleanup is part of the blackboard management contract |
BT-17-004 |
MUST | A consumer BT node that reads an output key set by a peer producer node MUST treat both KeyError (key never written) and a None value (producer took no-op path per BT-17-003) as equivalent no-op sentinels and skip any write-dependent action.A node that only handles KeyError but not None will incorrectly proceed with a None value on the second run after the producer has cleared the key to None. Both states mean "producer had nothing to produce this cycle." |
Refines: BT-17-003 |
BT-17-005 |
MUST | Tests for a received-side use case that exercises a BT guarded-commit path MUST pass receiving_actor_id equal to the actor holding CVDRole.CASE_MANAGER in actor_participant_index, not the CaseActor service entity ID.CheckIsCaseManagerNode compares actor_id against the participant registered with CVDRole.CASE_MANAGER in actor_participant_index, not against the CaseActor service resource ID. Passing the service entity ID causes the role check to fail silently: the guarded commit never fires, the RM state falls through to the deferred path, and the test may pass for the wrong reason. Issue #1050 discovered this after migrating AcceptInviteToEmbargoOnCaseReceivedUseCase to the single-BT pattern. |
Refines: BT-17-001 — The role guard identity check is what makes this fixture requirement necessary |
BT-17-006 |
MUST | A received-side use case that executes a BT containing GuardedCommitCaseLedgerEntryBT MUST call execute_with_setup with the receiving actor, never with request.actor_id (the sender). When request.receiving_actor_id is absent, the receiving actor MUST be taken to be the owner of the store the use case was handed, and the call MUST fail rather than proceed if neither source yields an identity. When a BT node inside that tree also needs to target the sender's participant (e.g. to advance their RM state), it MUST store the sender's ID in a private attribute (e.g. _target_actor_id) that DataLayerAction.setup() does not overwrite.*In received-side use cases, request.actor_id is the sender of the inbound activity; request.receiving_actor_id is the CASE_MANAGER that owns the ledger and whose role must be verified by CheckIsCaseManagerNode. Passing the sender ID as actor_id causes the role check to fail: the guarded commit never fires, and the ledger entry is silently skipped. EngageCaseReceivedUseCase and DeferCaseReceivedUseCase both carried this defect until issue #2300 corrected them. |
|
The fallback clause is a correction made in #2238. An earlier form of this entry named request.receiving_actor_id as the required argument outright, which forbade the fallback it should require: falling back to request.actor_id when the field is absent reinstates the sender, and under per-actor storage actor_id selects the store, so a fabricated or borrowed identity silently routes every read and write into a store other than the one whose replica is being updated. The store the use case was handed is the only defensible answer, because a received-side use case is by construction invoked with the receiving actor's store.* |
Refines: BT-17-005 — BT-17-005 covers tests; this entry covers production call sites | ||
BT-17-007 |
MUST | BTBridge.execute_with_setup MUST restore every blackboard key it or setup_tree writes to that key's pre-execution state before returning, on every outcome. That set MUST comprise the injected ports, actor_id, activity, and each **context_data key. It MUST also be extensible to a key written by a node mid-tree, where no single node can guarantee clearing it on every outcome because a memory=False Sequence may short-circuit before the owning node ticks. Because managed keys are restored, a caller MUST NOT use any of them as an output channel; a node returns a value to its caller through a mutable object the caller holds a reference to (e.g. a result_out dict), not through a managed key.Blackboard.storage is process-global, so a nested execute_with_setup that reuses a key overwrites the outer execution's value. actor_id was excluded until #3516 on the reasoning that the bridge holds its actor id as a local; that holds for the bridge and not for nodes, which re-read /actor_id in initialise() — run on every tick in which the node was not RUNNING. A nested call running as another actor leaked it to later siblings, reaching payloadSnapshot.actor (CLP-07-003). |
Refines: BT-17-003 — BT-17-003 covers node-owned output keys; this entry covers bridge-managed execution-scoped keys |
BT-17-008 |
MUST | Every emit node in the effect section of a received-side tree MUST sit inside the CASE_MANAGER gate (BT-17-001) or behind a check that the executing actor is the actor the emit speaks for. A received-side tree MUST NOT emit, as the executing actor's own act, an activity that another actor authored, and MUST NOT emit an activity whose only recipient is the executing actor. create_receive_activity_tree MUST be the only place a received-side tree acquires that gate: it MUST take the gated effects as manager_effects, the ungated effects as replica_effects, and the effects owed on a refusal as refusal_effects, which it gates on the CASE_MANAGER the same way (CLP-10-022), and it MUST refuse an emit-capable node in replica_effects unless the tree declares a named exemption carrying a reason.The AckReport received tree emitted Read(Offer) under the receiver's name for acknowledgements it never authored, and at the CASE_MANAGER its sole recipient was itself, so it looped (#2667). An on-behalf send names the doer in actor and the asker in attributedTo (CM-24-001, CM-24-002); re-publishing another actor's act is neither. A container never mails an actor it hosts (ADR-0109), so a self- addressed emit is a loop. |
Refines: BT-17-001 — BT-17-001 requires the gate for CASE_MANAGER-only subtrees; this entry says every received-side emit is gated by role or by sender |
Simulation Fuzzer Node Layout
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-16-001 |
MUST | Probabilistic simulation (fuzzer) BT nodes MUST reside in vultron/demo/fuzzer/ and MUST NOT be placed in vultron/core/behaviors/ or vultron/bt/.Demo/simulation nodes are external-dependency touchpoints, not production protocol behaviors. Keeping them in vultron/demo/fuzzer/ makes the simulation/production boundary explicit and prevents simulation artifacts from leaking into core protocol logic. |
|
BT-16-002 |
MUST | The probabilistic base types (WeightedBehavior subclasses) for simulation nodes MUST be defined in vultron/demo/fuzzer/base.py using the py_trees.behaviour.Behaviour base class.All py_trees-based BT execution uses the standard py_trees library (ADR-0008). Centralizing probabilistic base types in one module provides a single implementation point for all weighted-probability simulation nodes and avoids duplication across domain modules. |
Implements: BT-02-001 — py_trees.behaviour.Behaviour is the required base for all prototype BT nodes |
BT-16-003 |
MUST | Each simulation fuzzer node MUST be named after the external-dependency touchpoint it represents (e.g., EvaluateEmbargoProposal, DeployFix) and MUST include a docstring describing its semantic function in the CVD process, the category of external input it simulates (system integration, human decision, or environmental check), and its approximate success probability.Fuzzer nodes are named integration points where production logic will eventually replace the probabilistic stub. Explicit naming and documentation preserves the connection between simulation behavior and future implementation targets. |
|
BT-16-004 |
MUST | Simulation fuzzer modules MUST be organized by CVD domain: vultron/demo/fuzzer/base.py (probabilistic infrastructure), vultron/demo/fuzzer/embargo.py (embargo management), vultron/demo/fuzzer/messaging.py (inbound message handling), and vultron/demo/fuzzer/report_management/ (report lifecycle sub-workflows as parallel submodules).Domain-aligned module organization mirrors the workflow structure of the existing vultron/bt/ simulation and makes cross-references between simulation stubs and production use cases straightforward for future implementers. |
|
BT-16-005 |
SHOULD | Each simulation fuzzer node SHOULD be categorized in its docstring by automation potential: High (fully automatable via API or policy rule), Medium (partially automatable with human oversight required), Low (inherently human-driven; automation limited to notifications or task triggers), or TerminalPlaceholder (terminal placeholder node with no real decision logic — an AlwaysSucceed or AlwaysFail fallback leaf with no external dependency). Automation-potential categorization guides future implementers toward the correct integration pattern when replacing stubs with production logic, supporting the future real-integration roadmap. The TerminalPlaceholder value replaces the former ambiguous "N/A" label to make the meaning explicit and prevent it from being conflated with "not applicable due to high automation potential." |
Call-Out Point Abstraction Layer
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-18-001 |
MUST | Every call-out point node MUST describe its blackboard contract in its class docstring: the set of input keys read before dispatch and the set of output keys (with types) written to the blackboard on SUCCESS. The docstring describes the contract for a human reader; the authoritative, machine-readable declaration is the typed-port declaration required by BT-18-012, and the two MUST agree. The blackboard contract is the integration seam between the tree and the backend. Without an explicit contract, swapping from a fuzzer backend to a real implementation is a guess — the real backend may omit writing a key that a downstream node expects, causing silent failures. The docstring earns its place alongside the typed-port declaration because a reader deciding whether a capability fits their system should not have to reconstruct intent from port metadata; the declaration is what a test enforces, and the prose is what makes the seam legible. |
Implements: BT-16-003 — Call-out points are the named integration seam that BT-16-003 requires to be documented; the blackboard contract is the required documentation format for data-producing nodes. |
BT-18-002 |
MUST | A call-out point backend — fuzzer or real implementation — MUST write to all declared output keys when it returns SUCCESS. A backend MUST NOT return SUCCESS without writing the declared outputs. Downstream BT nodes rely on declared output keys being present after a call-out point succeeds. A fuzzer that returns SUCCESS without writing synthetic output to those keys produces a contract violation: a downstream node reads a missing key and fails, but the failure is attributed to the downstream node rather than the backend. Both fuzzer and real backends must be contract-conformant to make backend swaps transparent. |
|
BT-18-003 |
MUST | The fuzzer backend for a data-producing call-out point (Evaluator, Retriever, or Composer shape) MUST write synthetic but type-conformant data to the declared output keys. The synthetic data MUST be of the correct type; it does not need to be semantically meaningful. If the fuzzer backend omits output writes that a real backend would perform, the demo/simulation layer cannot serve as a faithful stand-in during development. Maintaining the same output contract ensures that demo scenarios exercise the same downstream code paths as production runs. |
|
BT-18-004 |
MUST | A call-out point node MUST be constructed via a backend factory callable. Factory-based injection is the established BT construction pattern in this codebase. Requiring the factory as a parameter (rather than hardcoding the fuzzer import) is what makes each call-out point an actual injection seam rather than a tightly-coupled stub. Without this, replacing any backend requires editing the tree-building function, defeating the abstraction layer's purpose. |
Implements: BT-04-001 — Factory-based injection for call-out points is an application of the existing factory pattern required for all BT construction. |
BT-18-008 |
MUST | Tree-building functions that contain call-out points MUST accept the backend factory as a parameter (or as an entry in a factory mapping). Accepting the factory as a parameter makes each call-out point an actual injection seam rather than a tightly-coupled stub; replacing a backend then requires no changes to the tree-building function. |
|
BT-18-009 |
MUST | The fuzzer factory MUST be the default value for the backend factory parameter of tree-building functions. A fuzzer default ensures demo scenarios work without explicit backend configuration until a real implementation is available, while still allowing injection of alternative backends at call sites that opt in. |
|
BT-18-005 |
MUST | The call-out point shape of a fuzzer node MUST be determined solely by the ADR-0024 seam-structure decision tree (how external input enters and output exits the call-out point). Automation potential MUST NOT be used as a criterion for assigning shape = ProtocolInternal. A node whose automation potential is High but whose inputs arrive from an external source or whose outputs affect an external system is a Retriever, Evaluator, Composer, or Actuator — not ProtocolInternal — regardless of how automated the implementation may be. Automation potential and call-out point shape are orthogonal dimensions. Automation potential describes whether the call-out point logic can be executed without human involvement; shape describes the structural pattern of the seam (what flows in, what flows out, and in what direction). A Retriever backed by a REST API is still a Retriever; the automation just means the Retriever implementation does not require human action. Conflating the two dimensions caused the FUZZ-08a misclassification tracked in issue #1188, where ~38 nodes with High automation potential were incorrectly assigned ProtocolInternal shape, causing downstream work (FUZZ-08a-bis, FUZZ-08d through FUZZ-08g) to build wrong seams. The canonical call-out shapes are enumerated by BT-18-013 (Actuator added in issue #1239; Sentinel removed as a shape by ADR-0097). Nodes that invoke external systems to cause side effects without producing a content artifact MUST be classified as Actuator, not Composer. |
Implements: BT-16-005 — BT-16-005 defines automation potential categories; BT-18-005 clarifies that those categories govern the automation-potential field only and have no bearing on call-out point shape assignment. |
BT-18-006 |
MUST | A node that queries an external system synchronously and returns only a binary (SUCCESS/FAILURE) result MUST be classified as a Retriever, not a Sentinel — and per BT-18-013 Sentinel is not a call-out capability shape at all, so Retriever is the only available classification. The defining criterion for Retriever shape is the synchronous on-demand query pattern, not the richness of the returned data; a boolean is the simplest possible structured fact a Retriever may return. A Sentinel, by contrast, monitors a condition continuously or is triggered by an external event and calls a trigger endpoint when the condition becomes true — the flow direction is Sentinel → trigger endpoint → protocol, not protocol → query → external system. The Sentinel vs. Retriever ambiguity surfaced with nodes such as MitigationDeployed, MitigationAvailable, and HaveExploit (issue #1240). These nodes appear sentinel-like because they return only SUCCESS/FAILURE, but they are invoked synchronously by the BT (on-demand) and query an external system to determine the current status. That is the Retriever pattern. A true Sentinel is not invoked by the BT; it runs independently and calls a trigger endpoint when a condition fires. Clarifying this distinction prevents future misclassification of on-demand binary-query nodes as Sentinels. |
Refines: BT-18-005 — BT-18-005 establishes that shape is determined by the seam-structure decision tree; BT-18-006 resolves the specific Sentinel vs. Retriever ambiguity for binary-result external queries. |
BT-18-010 |
MUST | A binary-result Retriever MUST declare an empty output-port set; the structured fact is expressed as the BT return value (SUCCESS/FAILURE), not as a blackboard write. As with every capability contract, the authoritative declaration is the typed-port declaration required by BT-18-012 and the node docstring describes it (BT-18-001). BT-18-001 through BT-18-003 require every call-out point to declare its complete blackboard contract. A binary-result Retriever has an empty output-key set; that empty set MUST still be explicitly declared so that the absence of output keys is documented as intentional rather than an oversight, and backend swaps remain transparent. An empty declaration is also what makes the BT-23-013 check decidable, because a capability that declares no output ports is not data-producing, so a bare constant-status default is a conforming default for it. |
Refines: BT-18-012 — BT-18-012 makes the typed-port declaration the authority for every capability contract; BT-18-010 states what that declaration contains for a binary-result Retriever — the empty set, declared rather than omitted. |
BT-18-007 |
MUST | An Evaluator call-out point that needs to block downstream BT execution — for any reason (editorial rejection, legal hold, compliance failure, or other non-approval decision) — MUST return Status.FAILURE from its update() method. Setting a structured output field to a "rejected" value while returning Status.SUCCESS does not block a Sequence and MUST NOT be used as a blocking mechanism. *A BT Sequence halts and returns FAILURE as soon as any child returns FAILURE; this is the universal py_trees blocking idiom. Relying on a field value (e.g., approved=False) to signal rejection is ineffective because the Sequence has no gate that reads it — only Status.FAILURE stops execution. This requirement resolves the deferred design question from ADR-0030 / issue #1312 (whether AdvisoryReviewDecision.approved=False should gate submission): the answer is no, because the field was redundant with Status.FAILURE. The approved field was removed from AdvisoryReviewDecision (issue #1783) to eliminate the trap. The principle generalises: any Evaluator whose output record carries a boolean approval flag is a design smell unless that flag is the sole input to a subsequent ProtocolInternal gate node. |
|
| * | Refines: BT-18-002 — BT-18-002 states that SUCCESS MUST accompany all declared output writes; BT-18-007 is the complementary rule — FAILURE is the correct signal when the Evaluator's decision is to block, regardless of output field values. | ||
BT-18-011 |
MUST | A call-out point backend MUST answer synchronously — its update() MUST return Status.SUCCESS or Status.FAILURE and MUST NOT return Status.RUNNING. This is the general case of the codebase-wide invariant that no Vultron BT node returns RUNNING (ADR-0080). A runtime guard at the call-out seam MUST reject a RUNNING return by raising an error (not a VultronError, so the bridge classifies it as an internal error rather than a protocol outcome), and a static architecture check MUST fail if any node under vultron/ returns Status.RUNNING. *Vultron's execution model has nowhere to suspend a tree — the bridge busy-loops on a root RUNNING until it exhausts max_iterations, then fails opaquely without naming the offending node. ADR-0080 establishes that a node asks and terminates rather than suspending. A call-out backend is injected from outside the repository, so a static scan cannot see it — only a runtime guard at the seam can. Raising a non-VultronError surfaces a mis-wired backend as an internal error. Source: CONCERN-3104. |
|
| * | Refines: BT-18-002 — BT-18-002 requires SUCCESS to accompany all declared output writes and BT-18-007 requires FAILURE to block; BT-18-011 closes the trichotomy by forbidding the third py_trees status, RUNNING, at the call-out seam. | ||
BT-18-012 |
MUST | A named capability MUST declare its blackboard contract as py_trees typed ports (ADR-0044 INPUT_PORTS / OUTPUT_PORTS) on a declaration owned by the core layer. The typed-port declaration is the authoritative contract; the class docstring required by BT-18-001 describes it but is not the authority. A capability declaration MUST NOT live in vultron/demo/. Because a shape base class is shared by many capabilities, it cannot carry a per-capability contract: the declaration MUST therefore be a core-owned per-capability artifact carrying that capability's own ports, and the probabilistic simulation node that stands in for it stays in vultron/demo/fuzzer/ (BT-16-001) and binds to the core declaration rather than restating the contract. | |
*BT-18-002 is enforceable only if the contract is machine-readable from the layer that must satisfy it. A contract expressed as docstring prose cannot be checked, and one expressed in vultron/demo/ cannot be read by core at all (BTND-04-002 forbids the import; BT-16-001 is why the probabilistic nodes are there) — which leaves the DETERMINISTIC default unable to write outputs it has no way to enumerate. Splitting the declaration from the simulation node is what lets both rules hold at once: core owns what the capability promises, demo owns one way of pretending to keep it. Typed ports are the mechanism ADR-0044 already mandates for every node in vultron/core/behaviors/, and they carry runtime type checks on both read and write, so reusing them makes the capability contract enforceable without a second registry for a problem already solved. Source: CONCERN-2835 (planning group G07), IDEA-2452. |
|||
| * | Refines: BT-18-001 — BT-18-001 governs the human-readable description of the contract; BT-18-012 governs the authoritative machine-readable declaration. The two describe one contract in two registers and must agree. | ||
Implements: BT-18-002 — BT-18-002's "declared output keys" are the declared output ports, which is what makes the requirement checkable rather than reviewable. |
|||
BT-18-013 |
MUST | There are exactly four call-out capability shapes: Evaluator, Retriever, Composer, and Actuator. Sentinel MUST NOT be classified as a call-out capability shape; it is a call-in integration pattern in which a process monitors a condition and acts on its own initiative rather than being consulted by the protocol. A Sentinel therefore has no call-out point node, no blackboard contract, no domain bundle field, and no backend factory. The classifying criterion is which party initiates, not whether the monitored information originates outside the case: a Sentinel MAY be a case participant that observes case state through ledger replication and acts by emitting protocol messages. | |
*A capability shape is defined by the call-out machinery it participates in: a backend factory, a domain bundle field, a blackboard contract, the synchronous-answer guard (BT-18-011), and a ceiling/floor default (BT-23-002). None of that applies to a monitor, because the protocol never invokes it — the flow runs monitor to trigger endpoint to protocol, the reverse of a call-out. Classifying a monitor as a capability shape therefore asserts a contract it cannot have, and invites the specific misclassification BT-18-006 exists to refuse. Naming it a call-in pattern states the flow direction that actually distinguishes it, and places it with the concerns an independently-acting process brings: its own schedule, its own credentials, and possibly its own protocol identity. Defining it by information source instead would misclassify a participant monitor, which reads the case's own replicated state and is nonetheless never consulted. Source: CONCERN-2835 (planning group G07). |
|||
| * | Refines: BT-18-006 — BT-18-006 classifies a synchronous on-demand external query as a Retriever; BT-18-013 is why Retriever is the only available answer, since Sentinel is not a call-out shape. | ||
BT-18-014 |
MUST | A question that a service the actor itself operates can answer within a single BT tick MUST be modelled as a call-out point. A question requiring a decision from another actor in the case MUST NOT be modelled as a call-out point; it is a protocol ask, in which the actor emits the request, terminates successfully, and acts on the reply when it arrives (ADR-0080). An action with no representable not-yet state MUST be refused rather than asked about. | |
*A call-out point is answered within the tick that asks, so it can only be used where an answer already exists to be fetched. A decision belonging to another actor does not exist at the moment of asking, so a gate modelled as a call-out point can only ever answer no — the condition ADR-0080 identified in the Case Owner approval gates. The two mechanisms are not interchangeable and the choice is forced by who holds the answer, not by convenience, so the criterion is normative rather than advisory. Source: CONCERN-2835 (planning group G07), IDEA-2453. |
|||
| * | Refines: RSH-07-004 — RSH-07-004 replaced the two status gates' call-out mechanism with a routing subtree; BT-18-014 generalises the criterion that forced it. | ||
Constrains: BT-18-005 — BT-18-005 assigns shape by seam structure once a seam is known to be a call-out point; BT-18-014 decides whether it is one at all. |
|||
BT-18-015 |
MUST | A call-out point backend MUST answer within a bounded time budget and MUST NOT perform unbounded blocking I/O in update(). The budget MUST be configurable per actor rather than fixed by this specification or hard-coded in an implementation, and a backend that exceeds it MUST surface as an internal error naming the offending node, not as a protocol FAILURE. | |
*A backend runs on the ticking thread under FastAPI BackgroundTasks, so unbounded blocking I/O in update() stalls a worker with no diagnostic while the bridge ticks toward max_iterations and then fails without naming the node — the same opaque failure BT-18-011 refuses, reached by blocking rather than by suspending. Answering synchronously is therefore necessary but not sufficient; the answer must also be prompt. The duration is not protocol because it is local to one actor and observable by no peer, so two deployments choosing different values is not divergence. An ask deadline is the opposite case in the respect that matters: it travels on the wire in the AS2 endTime field, so both parties read the same number (ASK-03-004). Both durations are actor configuration (compare ASK-03-005) — what separates them is wire visibility, not configurability. Source: CONCERN-2835 (planning group G07), IDEA-2453. |
|||
| * | Refines: BT-18-011 — BT-18-011 forbids suspending; BT-18-015 closes the remaining way a call-out can stall a worker — answering synchronously but too slowly. | ||
Refines: BT-18-014 — Work that cannot meet the budget is not a call-out point; BT-18-014 says what it is instead. |
|||
Routing-Gated State Mutation in Lifecycle BT Sequences
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-19-001 |
MUST | A BT Sequence that performs a state mutation and then routes an outbound message MUST resolve all routing prerequisites in a read-only guard node placed BEFORE the state-mutation node. The Sequence MUST fail at the guard with no state change when routing prerequisites are absent. Committing a state change before verifying that the resulting outbound message can be constructed and queued creates a divergence window: local state reflects the new value while peers still hold the prior value because the broadcast was never sent. Moving the routing guard before the mutation eliminates the most common divergence cause without requiring full transactional rollback. The outbox is the reliability boundary: once the message is written to the outbox, delivery and retry are the outbox's responsibility. |
Refines: BT-14-001 — Routing-gated mutation is the pre-condition counterpart to BT-14-001's fail-fast-on-broadcast-failure rule: guard before mutation addresses the common prerequisite-missing case; BT-14-001 addresses the post-mutation delivery failure case. Refines: BT-06-001 — State-mutation ordering relative to routing checks is part of the tree's auditable behavioral contract. |
BT-19-002 |
MUST | A shared BT factory function MUST be used for all paths that trigger the same protocol lifecycle transition (e.g., embargo termination). Duplicate monolithic BT nodes that inline both state mutation and activity dispatch in a single update() method MUST NOT exist alongside a canonical factory-composed equivalent.Duplicated implementations drift independently: the canonical trigger-side BT may correctly separate routing guards from state mutation while the automatic-cascade BT retains the old ordering or the old inline EMAdapter pattern, re-introducing the divergence bug in one pathway only. A single shared factory guarantees consistent semantics and the correct guard ordering across all call sites. Issue #1054 observed this exact drift between TerminateEmbargoNode (monolith, inline EMAdapter) and terminate_embargo_trigger_bt (factory-composed). Concretely: TerminateEmbargoNode (used by the since-deleted PublicDisclosureBranchNode) and TerminateEmbargoLifecycleNode + sender_side_bt (used by SvcTerminateEmbargoUseCase) represent the same protocol operation with divergent implementations — only the factory-composed path applied the correct routing-guard ordering (BT-19-001); the monolith committed state before verifying Case Manager availability. |
Refines: BT-14-002 — BT-14-002 requires shared helpers for broadcast fan-out; BT-19-002 extends this to the full lifecycle-plus-routing sequence. Implements: BT-19-001 — Shared factory composition is the mechanism that enforces consistent BT-19-001 guard ordering across all call sites. |
Production BT Collapse Patterns
Requirements for production BT subtrees that replace groups of simulator fuzzer nodes. Each collapse candidate reduces multiple granular simulator leaves to a smaller set of production call-out points with cleaner seam structure; individual requirements are provisional and subject to revision at implementation time.
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-20-001 |
SHOULD | The production create_acquire_exploit_strategy_tree factory SHOULD provide a HaveExploit Retriever and an EvaluateExploitStrategy Evaluator call-out point in place of the five simulator nodes (HaveExploit, ExploitPrioritySet, EvaluateExploitPriority, ExploitDeferred, ExploitDesired). The Evaluator SHOULD receive case context (vulnerability state, org policy, threat landscape, and the HaveExploit query result) and return a structured ExploitStrategyDecision record containing have_exploit, acquire, and rationale. The HaveExploit Retriever is kept as a separate seam for independent swapability (ADR-0027).The five-node simulator sequence encodes a single policy-driven decision ("should we acquire an exploit?") in granular boolean flags that are unnecessary in production. Collapsing to two call-out points (Retriever + Evaluator) reduces seam count while keeping the exploit-repo query independently swappable. Implemented by issue #1309 (PR #1566). See ADR-0027 and docs/developer/reference/bt-fuzzer/rm-exploit.md. |
|
BT-20-002 |
SHOULD | The production create_publication_tree factory SHOULD use PrioritizePublicationIntents (Evaluator) output to gate per-artifact arms directly, without separate NoPublish* bypass leaves or a PublicationIntentsSet flag check. The Evaluator SHOULD return a structured PublicationIntentDecision record containing publish_exploit, publish_fix, publish_report, and rationale. The intent record SHOULD drive which of three named arms (exploit, fix, report) execute; each arm SHOULD be a Selector(Sequence(ShouldPublishX, PrepareX, Publish), Inverter(ShouldPublishX)) so a not-intended arm is a graceful SUCCESS no-op. Each arm SHOULD consist of a Composer (prepare artifact) followed by a Publish pipeline (see BT-20-004).The NoPublish* bypass leaves and PublicationIntentsSet flag check are structural artifacts of the simulator BT representation; they do not represent real call-out points. In production the PrioritizePublicationIntents Evaluator output drives arm execution directly. The ShouldPublishX gate nodes are named positively per BTND-08-001. Implemented by issue #1310. See ADR-0028 and docs/developer/reference/bt-fuzzer/rm-publication.md. |
|
BT-20-003 |
SHOULD | The production report-to-others workflow SHOULD be driven by an external call-in monitor (the Sentinel pattern, BT-18-013 — not a call-out capability) that proactively queries the case for actors who are not yet participants but should be (party discovery), then fires suggest-actor-to-case for each candidate. The suggest-actor-to-case protocol (Offer → CASE_MANAGER → CaseOwner → Invite → Accept → Record cascade) handles the downstream flow; no inline tick-driven BT loop is needed at the outer level. If an inline subtree is required (e.g. for a single-pass trigger-driven invocation), it SHOULD consist of a Retriever call-out that returns candidate (actor_id, role) pairs and an Actuator per pair that fires suggest-actor-to-case with the explicit CVDRole parameter.*The original Production Collapse 3 design (#1311, ADR-0029) retained a three-typed-sub-loop BT structure whose inner blackboard queues (identified_vendors, identified_coordinators, identified_others) had no production mechanism to populate them. The identification step belongs in an external call-in monitor (see #2450) that periodically or event-triggered inspects the case and asynchronously fires suggest-actor-to-case for each uninvited candidate. Because that monitor is on the call-in surface rather than a call-out capability (BT-18-013), its seam is a trigger endpoint and not a bundle field. The downstream accept/invite/bootstrap cascade already works; no inline BT loop is required. The tick-driven create_report_to_others_tree module from #1311 was an implementation artifact of the intermediate design and has been removed (see issue #1848 and ADR-0047). |
|
| * | |||
BT-20-004 |
SHOULD | The production create_publish_artifact_tree factory SHOULD implement a multi-step pipeline replacing the single Publish Actuator leaf: at minimum (1) a Composer node drafts the advisory artifact from case data, (2) an Evaluator node reviews and approves the draft, and (3) an Actuator node submits the finalized artifact to the external advisory platform. An optional revision Composer MAY be included between the Evaluator and Actuator. Whether the review phase includes a broadcast-for-participant-comment step is an open design question to be resolved at implementation time.The single Publish simulator leaf stood in for a full advisory publication workflow. In production, drafting, review, and submission are distinct operations with separate external seams. The Actuator shape for submission is confirmed by ADR-0024 (Actuator amendment, 2026-07-07). See ADR-0030 (accepted 2026-07-22) and docs/developer/reference/bt-fuzzer/rm-publication.md. The participant-comment broadcast question in the review phase was deferred to a follow-on issue (AC-4, issue #1312). |
|
BT-20-005 |
SHOULD | The production create_deploy_mitigation_tree factory SHOULD implement a DeployMitigationBT Fallback tree with four arms: a short-circuit arm for already-active mitigations, a deferred-RM arm, a deploy-if-available arm, and a monitor-if-desired arm.*Mitigation deployment is the fallback path when a definitive fix is not yet available, as noted in the Phase 1 scoping of create_deploy_fix_tree (issue #1248). This makes MitigationDeployed and MitigationAvailable call-out Retrievers rather than DataLayerCondition nodes. |
|
| * | Extends: BT-23-003 — DeployMitigationCallOutBundle and DeploymentMonitoringBundle follow the domain bundle pattern from BT-23-003. | ||
BT-20-006 |
MUST NOT | The create_deploy_mitigation_tree tree MUST NOT include a CS state transition or protocol activity emission node; mitigation deployment is tracked externally (CMDB or case record) and the protocol has no dedicated mitigation-deployed message type.The VFD state machine tracks only vendor-aware/fix-ready/fix-deployed bits; there is no mitigation-deployed bit. Emitting a CS state transition or protocol activity from the mitigation tree would conflate fix-deployment semantics with mitigation tracking. |
|
BT-20-007 |
SHOULD | The shared monitoring/prioritization call-out seams (PrioritizeDeployment, MonitoringRequirement, MonitorDeployment) SHOULD be factored into a DeploymentMonitoringBundle base dataclass inherited by both DeployFixCallOutBundle and DeployMitigationCallOutBundle.Sharing the monitoring/prioritization seams via a base dataclass follows BT-23-003 (domain bundle pattern) and avoids field duplication across two closely-related bundles. |
|
BT-20-008 |
SHOULD | A create_deploy_tree combinator factory SHOULD compose fix and mitigation arms into an overarching DeployOrMitigateBT Fallback (fix arm first).The combinator tree delivers the stated goal of the parent epic (#1285) — a composed deployment decision tree that prefers fix deployment over mitigation deployment. |
Call-Out Point Backend Selection
Requirements for how running code selects and injects backend factories into BT tree builders. Covers the three-mode model (deterministic / stochastic / real), the domain bundle dataclass pattern, pre-built singletons, the CallOutBackendFactory Protocol, and the default direction rule. Derived from the planning session for issue #1631.
| ID | Priority | Requirement | Related |
|---|---|---|---|
BT-23-001 |
MUST | Call-out point backends MUST be organised into three logical modes: DETERMINISTIC (AlwaysSucceed/AlwaysFail derived from the ceiling/floor of stochastic probabilities), STOCHASTIC (probabilistic fuzzer classes), and REAL (production implementations — currently deferred to FUZZ-08d through FUZZ-08h). The DETERMINISTIC mode MUST be the default for all tree builders, integration tests, and demo scenarios that do not explicitly opt into STOCHASTIC or REAL. | |
*Deterministic defaults make integration tests and demo scenarios repeatable without per-call-site configuration. The probabilistic (stochastic) mode is useful only for simulation and fuzz-testing; making it the default (as in the original fuzzer heritage) causes non-determinism in CI. The three-mode naming also documents the intended migration path: DETERMINISTIC → REAL as production implementations are built. |
|||
| * | Extends: BT-18-004 — BT-18-004 requires factory injection; BT-23-001 specifies that the default factory must be the DETERMINISTIC backend, not the stochastic fuzzer. | ||
BT-23-002 |
MUST | When the stochastic success probability p > 0.5, the DETERMINISTIC backend for that call-out point MUST be AlwaysSucceed. Grounding the deterministic backend in the stochastic probability preserves the semantic intent of each node. Nodes that the simulation expects to usually succeed map to AlwaysSucceed so that deterministic demo scenarios make forward protocol progress. |
Refines: BT-23-001 |
BT-23-006 |
MUST | When the stochastic success probability p < 0.5, the DETERMINISTIC backend for that call-out point MUST be AlwaysFail. Nodes that the simulation expects to usually fail map to AlwaysFail, preserving the semantic intent that such a call-out point is unlikely to succeed in a realistic scenario. |
|
BT-23-007 |
MUST | When the stochastic success probability p == 0.5, the DETERMINISTIC backend for that call-out point MUST be AlwaysSucceed (happy-path default). The p == 0.5 tie-breaking rule selects the happy-path direction so that deterministic demo scenarios make forward protocol progress when the simulation assigns equal probability to success and failure. |
|
BT-23-003 |
MUST | Call-out point factories for a domain area MUST be collected into a domain bundle — a frozen @dataclass (one per domain area) whose fields are typed CallOutBackendFactory values.*Individual per-node factory kwargs accumulate quickly (a tree builder may have 5–10 call-out points); a bundle dataclass keeps the tree builder signature clean while enforcing completeness (all fields must be supplied). The frozen constraint prevents accidental mutation after construction. |
|
| * | Refines: BT-23-001 Extends: BT-18-004 — Replaces the individual factory-kwarg API with the bundle API. |
||
BT-23-008 |
MUST | The domain bundle dataclass and its DETERMINISTIC singleton (e.g., VALIDATION_DETERMINISTIC) MUST be defined in the core layer (vultron/core/behaviors/call_out/bundles/<domain>.py).The DETERMINISTIC bundle is the production-usable happy-path backend a real actor uses before a capability implementation is wired in, so a core tree builder must resolve its default without reaching into the simulation layer. Locating it in vultron/demo/ inverts the core→demo dependency (corrects the original 2026-07-23 amendment). |
|
BT-23-009 |
MUST | The STOCHASTIC singleton (e.g., VALIDATION_STOCHASTIC) MUST be defined in the simulation layer (vultron/demo/fuzzer/bundles/<domain>.py).The probabilistic STOCHASTIC wiring is a simulation artefact; keeping it in vultron/demo/ respects the BT-16-001 rule that simulation artefacts do not appear in vultron/core/. |
|
BT-23-010 |
MUST | Tree builders that accept call-out factories MUST accept the bundle as a single typed parameter (e.g., call_out: ValidationCallOutBundle) rather than individual per-node factory kwargs.A bundle parameter keeps the tree builder signature clean and enforces completeness — all fields must be supplied, making missing call-out points a static type error rather than a silent runtime omission. |
|
BT-23-011 |
MUST | Tree builders MUST default the bundle parameter to the core DETERMINISTIC singleton without importing from vultron/demo/ (per BT-16-001 and ARCH-01-001).Pre-built singletons avoid non-DRY construction in every demo script and test fixture. Defaulting to the core DETERMINISTIC singleton ensures the tree builder has no simulation-layer import dependency. |
|
BT-23-004 |
MUST | The CallOutBackendFactory type MUST be defined as a typing.Protocol (not a bare Callable type alias). The Protocol MUST declare a single call method accepting name: str and returning py_trees.behaviour.Behaviour. Static type checking (mypy/pyright) is the validation mechanism; no central registration or base class inheritance is required. | |
*A Protocol produces IDE completion and pyright/mypy enforcement of the factory signature without requiring runtime registration overhead. Duck- typing via Protocol is consistent with how other ports in the codebase are typed (vultron/core/ports/use_case.py), and avoids the coupling risk of a central registry (name-key typos are silent runtime failures rather than static errors). |
|||
| * | Refines: BT-23-003 Extends: BT-18-004 |
||
BT-23-005 |
SHOULD | Domain bundle classes SHOULD follow the naming convention |
|
*Consistent naming makes the available bundles discoverable without reading the implementation. Splitting the seam by layer keeps the deterministic, production-usable default in core while co-locating the STOCHASTIC wiring with the probabilistic fuzzer nodes it references, respecting the BT-16-001 rule that simulation artefacts stay in vultron/demo/ and do not appear in vultron/core/. The core→demo boundary is enforced by test/architecture/test_core_no_demo_imports.py. |
|||
| * | Refines: BT-23-003 | ||
BT-23-012 |
MUST | A call-out gate whose permissive backend would let a party other than the Case Owner unilaterally cause canonical case-state adoption or embargo teardown on the received side MUST default to the conservative (floor) backend regardless of its stochastic success probability. The ceiling/floor rules BT-23-002, BT-23-006, and BT-23-007 apply only to gates that do NOT have this property. | |
*BT-23-002/006/007 select the DETERMINISTIC backend purely from the simulation success probability, which for a security-significant gate maps a high-p node to the permissive AlwaysSucceed — the exact defect CONCERN-2092 filed. ADR-0076 established the project-wide exception: for a gate where permissiveness is a protocol-security risk rather than a prototype-stage convenience, the floor (most restrictive semantically-correct backend) is the default and permissiveness is an explicit, documented operator opt-in. This entry lifts that exception from the two named status gates (RSH-07-001, RSH-07-002) to a general rule that binds every future gate author, so the classification is applied when a gate is added rather than rediscovered by audit. The current membership of the security-significant set, and the rationale for gates that look similar but do NOT qualify (the accept-direction CaseOwnerApprovesEmbargoResponse, which EMB-15-001 requires to default accept; the trigger-side, fail-closed-by-default EmbargoExitPolicyGuard), is design analysis and lives in notes/call-out-configuration.md, not here. This entry governs the default-selection POSTURE (conservative floor, not p-driven), which is orthogonal to how a qualifying gate is COMPOSED: for the two status gates specifically, RSH-07-004 (ADR-0080, CONCERN-2812) supersedes the interim unconditional-FAILURE RequireCaseOwnerApprovalNode mechanism with a conversation-state routing subtree. The conservative posture BT-23-012 mandates is unchanged, but a compliant implementation MUST NOT rely on a CallOutBackendFactory that returns an unconditional FAILURE as that default (RSH-07-004 verification). |
|||
| * | Supersedes: BT-23-002 — Carves out security-significant gates from the p>0.5 → AlwaysSucceed rule; the same carve-out applies to BT-23-006 and BT-23-007. | ||
Refines: RSH-07-001 — Generalizes the two-gate conservative-default requirement. Refines: RSH-07-004 — Posture (conservative default) is orthogonal to composition; RSH-07-004 replaces the unconditional-FAILURE mechanism with a routing subtree without changing the default posture BT-23-012 mandates. |
|||
BT-23-013 |
MUST | For a capability that declares one or more output ports, the DETERMINISTIC backend MUST write type-conformant values to every declared output port when it returns SUCCESS — notwithstanding BT-23-002 and BT-23-007, which name AlwaysSucceed by class. Where this requirement and those apply to the same capability, this one governs: a bare constant SUCCESS backend MUST NOT be used as the DETERMINISTIC default for a capability that declares output ports. BT-23-002 and BT-23-007 remain authoritative for the SUCCESS/FAILURE direction, which this requirement does not change. | |
*BT-23-002 and BT-23-007 select the deterministic backend so that deterministic demo scenarios make forward protocol progress. For a capability that produces data, a bare constant SUCCESS defeats that goal instead of serving it: the capability reports success, the downstream gate reads an absent key, and the arm silently no-ops. Forward progress through a data-gated arm requires the data, so the status direction alone is not a sufficient specification of the default. The failure mode is silent rather than loud, because a downstream read declared optional yields FAILURE rather than an error, which is why the obligation is stated as a requirement on the default rather than left to be noticed. BT-18-012 makes the declared contract readable from core, which is what makes a conforming default possible. The same obligation binds a STOCHASTIC backend under BT-18-003, including the generic WeightedBehavior backends used for production-only domains that have no named fuzzer node. Source: CONCERN-2835 (planning group G07). |
|||
| * | Supersedes: BT-23-002 — BT-23-002 names AlwaysSucceed by class; for a data-producing capability BT-23-013 substitutes a contract-honouring backend in the same SUCCESS direction. The same constraint applies to BT-23-007. | ||
Constrains: BT-23-012 — BT-23-012 and BT-23-013 are the two constraints on the ceiling/floor rule, and they do not overlap: BT-23-012 overrides the status direction for a security-significant gate, BT-23-013 overrides the backend class for a data-producing capability while leaving the direction intact. A gate covered by BT-23-012 declares no output ports, so BT-23-013 does not reach it. |
|||
Implements: BT-18-002 — BT-23-013 applies BT-18-002 to the specific backend that BT-23-001 makes the default everywhere. |
|||
Refines: BT-18-003 — BT-18-003 requires type-conformant synthetic writes from a fuzzer backend; BT-23-013 extends the same obligation to the DETERMINISTIC default and to generic stochastic backends with no named fuzzer node. |
|||
Behavior Tree Node Design
Formal requirements for BT node parameterization, composability, and reuse in the core behaviors package. Covers node context injection, blackboard hygiene, module ownership, and the fractal composability pattern that applies at every depth of the tree hierarchy.
File ID: BTND
Node Parameterization
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-01-001 |
MUST NOT | BT nodes MUST NOT hard-code actor identities, CVD roles, case IDs, or any context that varies by usage site; these values MUST be constructor parameters. Hard-coded context values prevent reuse across actors or scenarios and couple the node to a single workflow. |
Refines: BT-06-004 |
BTND-01-002 |
MUST NOT | BT nodes MUST NOT read actor identity, case ID, or CVD role from environment variables, module-level globals, or any implicit ambient context. All variable inputs MUST arrive via constructor parameters or the documented blackboard interface. | |
BTND-01-003 |
MUST | When a BT node name is derived from a parameterized value (e.g., role or actor ID), the name MUST be constructed from that parameter at instantiation time, not hard-coded as a string literal. | Refines: BT-06-004 |
Composability and Reuse
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-02-001 |
MUST | Logic that appears in two or more BT nodes or subtrees MUST be extracted into a shared, parameterized node or factory function in a neutral shared module (see BTND-04-001). |
Refines: BT-06-004 |
BTND-02-002 |
SHOULD | Each BT subtree SHOULD be independently triggerable as a use-case entry point AND composable as a child of a larger subtree. This is the "fractal composability" pattern. Subtrees that can only be used one way are candidates for refactoring. |
Refines: BT-06-002 |
BTND-02-003 |
SHOULD | New BT subtrees SHOULD be verified against notes/vultron-bt.txt (the canonical simulation BT structure) to confirm structural correspondence before committing. |
Refines: BT-06-002 |
BTND-02-004 |
SHOULD | A shared BT node or subtree that models a generic operation (e.g., "create a case participant with any role") SHOULD replace all role-specific one-off variants (e.g., CreateReporterParticipantNode, CreateVendorParticipantNode) once the shared version is available.Reduces maintenance surface; fixes and improvements apply consistently across all use sites. |
Blackboard Interface Contracts
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-03-001 |
MUST | Each BT node that reads from or writes to the py_trees blackboard MUST declare all blackboard keys it uses in its setup() method via register_key().Undeclared blackboard reads create hidden dependencies that break composability and make tests fragile. |
Refines: BT-03-003 |
BTND-03-002 |
MUST NOT | A BT node MUST NOT read a blackboard key that was not registered with at least READ access in setup(). |
Refines: BT-03-003 |
BTND-03-003 |
SHOULD | A BT node's docstring SHOULD document all blackboard keys it reads and writes, including the expected type and the node that produces the value for each READ key. | |
BTND-03-004 |
MUST | A BT node that writes an inter-node handoff key (a blackboard key written by one node and consumed by a downstream sibling within the same tree) MUST namespace the key by the execution-scoped correlation ID of the tree instance, using the {noun}_{id_segment} convention (id_segment = recommendation_id.split('/')[-1] for HTTP URIs, or the last colon-delimited segment for URN IDs). The same namespaced key MUST be used consistently by all nodes in the tree that read or write that value.The py_trees blackboard is process-global. When a tree factory is called concurrently (e.g., two Offer(Actor, Case) messages arriving in parallel futures), two tree instances share the same blackboard namespace. A flat key like suggested_roles written by the first instance is silently overwritten by the second before the first instance reads it — causing the wrong role list to be emitted to the Case Owner. Namespacing by the execution-scoped ID (e.g., suggested_roles_abc123) guarantees collision-free per-execution storage. See notes/bt-pitfalls.md § "Namespaced Inter-Node Handoff Keys". Discovered in CONCERN-1335. |
Refines: BTND-03-001 |
BTND-03-005 |
MUST NOT | Blackboard keys MUST NOT contain forward-slash characters. py_trees parses forward-slashes as namespacing separators; a key containing / silently creates a sub-namespace and will not be found by flat-key readers. |
Refines: BTND-03-004 |
BTND-03-008 |
MUST | Blackboard keys MUST follow the {noun}_{id_segment} naming convention where the noun names the value and id_segment is the execution-scoped correlation ID (last path or colon-delimited segment). Flat, unscoped keys (e.g., result, status) are only permitted for top-level handoff keys that are consumed in the same tick cycle and cannot collide.The {noun}_{id_segment} convention generalizes BTND-03-004 to all keys, not just inter-node handoffs. |
Refines: BTND-03-004 |
BTND-03-006 |
MUST | A decomposed BT leaf node that encounters a domain-layer error MUST write the error to the result_out["error"] blackboard key before returning FAILURE, and MUST re-raise the original exception in execute() after the write so that the bridge layer can map it to the corresponding HTTP error response.Without a written error key the bridge layer sees only FAILURE with no diagnostic context; the error is silently swallowed. Writing to result_out["error"] ensures the error propagates to the caller via the result channel even when the BT stops traversal at the failing leaf. |
Refines: BTND-03-001 |
BTND-03-007 |
MUST | A decomposed BT leaf node MUST convert KeyError (missing blackboard key) to a FAILURE status and MUST NOT allow the KeyError to propagate uncaught to the bridge layer. The leaf is responsible for catching KeyError in its update() method and returning FAILURE with a diagnostic message naming the missing key.A KeyError propagating to the bridge results in an unhandled 500-level error for the caller. At the leaf level the semantics of "key not present" are known — this is a precondition failure, not an unexpected crash, so FAILURE is the correct BT response. |
Refines: BTND-03-001 |
BTND-03-009 |
MUST | New BT nodes in vultron/core/behaviors/ MUST subclass DataLayerConditionWithPorts or DataLayerActionWithPorts (from helpers.py) rather than DataLayerCondition / DataLayerAction. The typed INPUT_PORTS / OUTPUT_PORTS class attributes constitute the node's formal blackboard contract and MUST declare every port the node reads or writes. A subclass extends its parent's ports by spreading the parent attribute ({**Parent.INPUT_PORTS, ...}) and MUST NOT override the input_ports() / output_ports() accessors.Typed port declarations are machine-readable, appear at the class level, and are the single source of truth for a node's data-flow contract. The imperative register_key() approach scatters declarations across setup(), initialise(), and update(). py_trees >= 2.6 treats a ports class lacking either attribute as abstract and refuses to instantiate it, so an accessor override is not a valid declaration (#3610, |
Extends: BTND-03-001 Refines: BTND-03-003 |
BTND-03-010 |
MUST | A DataLayerConditionWithPorts or DataLayerActionWithPorts subclass MUST call setup_ports() in its setup() method with port remappings {"datalayer": "/datalayer", "actor_id": "/actor_id"} (plus "trigger_activity_factory": "/trigger_activity_factory" for action nodes). These absolute-path remappings wire the node's declared ports to the flat keys written by BTBridge.setup_tree(). |
|
*The BehaviourWithPorts default namespace scopes port keys under the node's subtree path, which does not match the flat /datalayer and /actor_id keys that BTBridge.setup_tree() writes. Explicit absolute-path remappings are required for compatibility. The base classes DataLayerConditionWithPorts and DataLayerActionWithPorts implement this in their own setup() so subclasses that do not add extra ports need not override setup(). |
|||
| * | Refines: BTND-03-009 | ||
BTND-03-011 |
MUST | A DataLayerConditionWithPorts or DataLayerActionWithPorts subclass MUST read injected values via get_input(port_name) in initialise() (not via direct self.blackboard.<key> attribute access). A missing required port raises NoDataAvailable when get_input() is first called in initialise() — at the start of the first tick, not during setup().Direct blackboard attribute access bypasses the typed port layer and its early error detection. Using get_input() preserves type checking and NoDataAvailable detection for required ports. |
Refines: BTND-03-009 |
BTND-03-012 |
MUST | A DataLayerActionWithPorts subclass that writes an inter-node handoff key MUST declare that key as an output port in OUTPUT_PORTS and write values via self._set_output(port_name, value) rather than via direct self.blackboard.<key> = value attribute assignment. Output port remappings MUST be included in the port_remappings dict passed to setup_ports() (typically via _domain_port_remappings()). |
|
*Declaring output ports in OUTPUT_PORTS makes the node's write contract explicit and machine-readable, consistent with the input-port convention from BTND-03-009/BTND-03-011. Using _set_output() routes writes through the typed port layer so the framework enforces that only declared output keys are written. |
|||
| * | Refines: BTND-03-009 | ||
BTND-03-013 |
MUST | Where the physical blackboard key for an output port is execution-scoped (computed from constructor parameters per BTND-03-004, e.g. suggested_roles_abc123), the node MUST declare a stable logical port name in OUTPUT_PORTS using only the {noun} part (no _{id_segment} suffix), and MUST bind the physical execution-scoped key by overriding setup() to pass port_remappings={"logical_name": physical_key} to setup_ports(). The logical name is the public contract; the physical key is the per-execution wire-up. |
|
* OUTPUT_PORTS is a class attribute with no access to instance state, so execution-scoped physical keys (computed in __init__) cannot be declared there directly. Declaring a stable logical name and binding the physical key in setup() separates the interface (class attribute) from the execution-time binding (instance method), preserving BTND-03-004 concurrent-execution safety while enabling typed port declarations. Validated against py_trees.ports.PortsMixin.setup_ports() remapping semantics. |
|||
| * | Refines: BTND-03-012 Refines: BTND-03-004 |
Module Ownership
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-04-001 |
MUST NOT | BT nodes or subtrees that are used by more than one domain module (e.g., by both case/ and report/) MUST be defined in the shared vultron/core/behaviors/helpers.py module or a purpose-built shared module within vultron/core/behaviors/. Domain-specific modules MUST NOT import BT nodes from sibling domain modules.Cross-domain node imports create tight coupling and circular-import risk. Neutral shared ownership makes the dependency structure explicit. |
Refines: ARCH-04-001 |
BTND-04-002 |
MUST NOT | BT nodes MUST NOT import from vultron/demo/ or any other demo-layer module. Demo-specific context (step descriptions, demo loggers, demo flags) belongs in demo scripts, not in BT nodes. |
|
BTND-04-003 |
MUST NOT | behaviors/ modules MUST NOT import from use_cases/ modules. BT nodes MUST NOT import use-case classes or functions.Importing use-cases from behaviors inverts the composition hierarchy (behaviors are the implementation layer of use-cases, not their consumers) and introduces circular imports when use-cases inject behaviors via factories. |
Refines: BTND-04-001 Refines: ARCH-04-001 |
BTND-04-004 |
MUST | Logic shared between behaviors/ and use_cases/ MUST be extracted to a common helper in vultron/core/ or vultron/core/behaviors/helpers.py. |
Refines: BTND-04-003 |
Actor Configuration for Participant Nodes
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-05-002 |
MUST | The BT node for creating the initial case participant MUST be role-agnostic; the correct node for this purpose is AddOwnerParticipantNode, which takes the case owner's additional roles from ActorConfig.default_case_roles."Vendor" is a demo-specific assumption. In the general protocol a coordinator, deployer, or other role may equally receive a report and become the case owner. |
Refines: BTND-01-001 |
BTND-05-003 |
MUST | Participant-creation BT nodes MUST build and attach the CaseParticipant record through the shared _create_and_attach_participant helper (vultron/core/behaviors/case/nodes/participant/common.py) rather than re-implementing create-and-attach per flow. The helper carries no status or embargo-consent policy; each seating flow (case owner, inert invitee, reporter) supplies its own roles and initial status. No role-specific participant-creation subclasses or alias names are permitted.Participant creation differs by protocol moment — case creation, stub-Invite seating (CM-11-006), reporter seating — not by role. The invitee flow must seat an inert, non-signatory participant (ADR-0114), so a single composite creation node bundling embargo-signatory seeding (the retired CreateCaseParticipantNode) could not serve every flow. Centralizing the policy-free create-and-attach in _create_and_attach_participant keeps the mechanism DRY without forcing an incompatible policy. |
|
BTND-05-004 |
MUST | The CVDRole enum MUST include a CVE_NUMBERING_AUTHORITY value representing a participant that holds CVE Numbering Authority (CNA) status.Multiple participants in an MPCVD case may independently be CNAs (e.g., a vendor CNA and a coordinator CNA). The IsIDAssignmentAuthority BT node checks whether a participant holds this role as a static capability check; IdAssignable evaluates whether the CNA's scope rules cover the specific vulnerability. Without a CVE_NUMBERING_AUTHORITY role in CVDRole, the protocol has no way to represent CNA status, route ID-assignment decision nodes to the correct participant(s), or distinguish CNA participants from non-CNA participants in case actor lists. CVE_NUMBERING_AUTHORITY is orthogonal to all other CVD roles — a VENDOR, COORDINATOR, or any participant may independently hold it. |
Refines: BTND-05-001 |
BTND-05-005 |
MUST NOT | The BT node for creating the initial case participant MUST NOT assume any specific CVD role (e.g., Vendor). Any CVD actor (coordinator, deployer, vendor, etc.) may receive a report and become the case owner; hardcoding a role couples the node to a single scenario. |
|
BTND-05-006 |
MUST | The BT node for creating the initial case participant MUST treat the actor as the case owner regardless of their CVD role(s). Case ownership is orthogonal to CVD role. The node must apply the CASE_OWNER flag without requiring or checking any specific CVD role value. |
|
BTND-05-007 |
SHOULD | CVE eligibility checking SHOULD be implemented as a single EvaluateCveEligibility Evaluator call-out point rather than as multiple individual call-out points for each eligibility criterion.CVE eligibility is one logical capability: a set of criteria evaluated as a unit against a specific version of the CNA Operational Rules. Treating each criterion as an independent call-out point misrepresents the substitution unit — when the rules are updated, all criteria change together, not independently. A single Evaluator call-out correctly models the "replace the whole eligibility check" seam (ADR-0071). |
Refines: BTND-05-004 |
BTND-05-008 |
SHOULD | The EvaluateCveEligibility call-out point SHOULD be grounded in the CNA Operational Rules edition endorsed by the current spec as the conformance baseline. The reference implementation follows CNA Operational Rules v4.1.0. Adopting a newer edition requires updating both this spec and the implementing call-out factory.Pinning the eligibility logic to a specific rules edition in the implementation (not the RFC) avoids coupling the protocol spec to an independently-versioned external document's release cycle, while remaining transparent about which edition serves as the conformance baseline (ADR-0071). |
Refines: BTND-05-007 |
Idiomatic BT Construction Patterns
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-06-001 |
SHOULD | Stateful BT nodes SHOULD use the implicit sequence pattern: a Fallback whose first child checks whether the goal is already achieved (the postcondition), and whose remaining children perform the work only when the goal has not yet been reached. Automatically makes nodes idempotent and reactive. Re-ticking a satisfied node costs only a cheap condition check. This is the dominant pattern throughout vultron/bt/. |
Refines: BT-06-003 |
BTND-06-002 |
SHOULD | When a Sequence contains multiple actions, each action that may already be complete SHOULD be paired with its success condition via a Fallback, making the explicit success condition visible in the tree. Improves readability and correctness. Readers can determine the success criterion of each step from the tree alone, without reading action implementations. |
Refines: BT-06-004 |
BTND-06-003 |
SHOULD | Every BT node that performs a protocol state transition (RM/EM/CS state machine update, emitting an AS2 activity, or creating/updating a domain object) SHOULD follow the PPA (Postcondition–Precondition–Action) structure: Fallback(Postcondition, Sequence(Precondition1, …, PreconditionN, Action)).PPA is the canonical "unit cell" of idiomatic BT design. It makes the node's contract (what it requires and what it guarantees) explicit and machine-readable from the tree structure. |
Refines: BT-06-004 |
BTND-06-004 |
MAY | Multi-step workflows where each step has its own preconditions that may need to be achieved MAY be constructed using back-chaining: start with the goal condition, find the PPA whose postcondition matches, and recursively substitute failing preconditions with their own PPAs. Back-chaining produces fully reactive, goal-directed trees where intermediate states are automatically recovered on failure. |
Refines: BT-06-002 |
BTND-06-005 |
MUST | Before any irreversible or protocol-significant action, a safety guard condition MUST be the first child of the enclosing Sequence, so that the action is skipped when the guard fails. Prevents unsafe state transitions when the world changes between ticks. In a multi-party protocol, another actor may change shared state at any time. |
Refines: BT-06-003 |
BTND-06-006 |
MAY | Memory composites (memory=True in py_trees) MAY be used only in contexts that are fully deterministic and non-reactive: no external actor can undo a child's outcome between ticks, and the agent operates in a closed-loop, human-supervised environment. |
Refines: BT-06-003 |
BTND-06-007 |
SHOULD | A behavior SHOULD be implemented as a leaf node when the action is atomic and has no observable sub-steps. Leaf nodes are the appropriate granularity for indivisible actions; decomposing them further adds structural complexity without improving testability. |
Refines: BT-06-004 |
BTND-06-008 |
MUST | Code that writes to a memory=False py_trees Sequence MUST be written assuming that any child's writes to the blackboard are immediately visible to subsequent children but are NOT rolled back if a later child returns FAILURE. A memory=False Sequence is non-transactional: partial side-effects from earlier children survive the composite's final FAILURE status.The py_trees memory=False Sequence restarts from the first child on each tick, but it does NOT undo blackboard writes made by children in previous ticks or earlier in the same tick. This surprises developers who expect Sequence failure to be atomic. The correct pattern for atomic multi-write operations is a single leaf node that performs all writes. |
Refines: BTND-06-001 |
BTND-06-009 |
SHOULD | A behavior SHOULD be decomposed into a subtree when it can be split into independently verifiable pre/post-condition pairs. Decomposing into subtrees with explicit pre/post-condition pairs makes each step individually testable and follows the PPA pattern (BTND-06-003). |
|
BTND-06-010 |
SHOULD | A subtree SHOULD be extracted when the same multi-step pattern appears in more than one place (see BTND-02-001). Extracting repeated patterns into a shared subtree reduces duplication and ensures fixes and improvements apply consistently across all use sites. |
BT Node Module Organization
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-07-001 |
MUST | BT node classes for a given protocol step MUST be organized into a nodes/ subpackage rather than a single flat nodes.py module. This applies to all BT-bearing process areas in vultron/core/behaviors/ regardless of current node count or file size.A flat nodes.py becomes a high-churn, high-blast-radius file where every change — regardless of which workflow step it touches — modifies the same module. Applying the nodes/ subpackage pattern uniformly (not just when a file grows large) keeps the hierarchy consistent and prevents gradual accumulation that creates a large-file problem later. |
Refines: BT-06-004 |
BTND-07-006 |
MUST | Within a nodes/ subpackage, submodules MUST be grouped by semantic concern (e.g., conditions.py, case_setup.py, participant.py, embargo.py, communication.py, lifecycle.py).Grouping by semantic concern reduces diff scope, improves discoverability, and lets reviewers focus on the affected concern in isolation. |
Refines: BTND-07-001 |
BTND-07-007 |
MUST | The nodes/ subpackage __init__.py MUST re-export all public node names to preserve import compatibility with callers. |
Refines: BTND-07-001 |
BTND-07-002 |
SHOULD | The test suite for a nodes/ subpackage SHOULD mirror the subpackage structure: a test/…/nodes/ directory with one test_<submodule>.py per submodule, so that each concern's tests are co-located and independently runnable.A monolithic test_nodes.py grows in parallel with the flat nodes.py and inherits the same blast-radius problem. Mirrored test files scope test failures to the affected concern and make it easier to add focused per-node unit tests. |
Refines: BTND-07-001 |
BTND-07-003 |
MUST | Each BT-bearing process area directory under vultron/core/behaviors/ MUST maintain a clear structural split between tree factory modules and leaf node modules. Tree factory modules (files named *_tree.py at the area root) define BT composition and structure. Leaf node modules (files in the nodes/ subpackage) define BT Behaviour subclasses. Supporting files (e.g., policy.py, update_support.py) MAY exist at the area root alongside tree modules. This two-level hierarchy MAY be deepened further: a nodes/ subpackage MAY itself contain sub-subpackages when a semantic concern grows large, following the same split pattern recursively.Mixing tree factory functions and leaf node class definitions in the same module conflates two separate concerns: composition/structure (how the tree is assembled) and implementation/detail (what each leaf node does). Keeping them in separate modules makes the tree structure immediately readable without navigating past implementation details, and lets each concern evolve independently without cross-contaminating diffs. |
Refines: BTND-07-001 |
BTND-07-004 |
MUST | BT leaf node modules under vultron/core/behaviors/**/nodes/ MUST stay under 500 lines each. A leaf module that exceeds 500 lines MUST be further decomposed into concern-focused modules or subpackages.Subpackage splits fail when they recreate the same god-module risk in a new file. A hard per-module cap keeps decomposition recursive and auditable. |
Refines: CS-18-002 |
BTND-07-005 |
MUST | Before implementing guard, validation, or factory-call boilerplate in a new BT leaf node's update() method, the implementer MUST search vultron/core/behaviors/ for an existing base class or helper that already provides that boilerplate and inherit or reuse it.The most common cause of god-node update() methods is copy-paste of guard and factory-call boilerplate from prior nodes. Each copy drifts independently, accumulates line count, and must be fixed separately. A shared base class keeps update() methods to a single try-block orchestration with all logic in overridable hooks, satisfying BTND-07-003 and the DRY principle simultaneously. |
Refines: BTND-07-003 |
BTND-07-008 |
MUST NOT | A new BT leaf node MUST NOT re-implement self.datalayer, self.actor_id, or self.trigger_activity_factory resolution inline; DataLayerAction already provides these lookups.Inline resolution duplicates logic from DataLayerAction, drifts independently, and increases line count without benefit. |
|
BTND-07-009 |
MUST | A new BT leaf node that follows the standard guard + factory-dispatch + outbox-write pattern MUST subclass an existing emit-node base class (e.g., _EmitCaseActorReportActivityBase) and override _call_factory() rather than writing a new update() from scratch.Subclassing an existing emit-node base class reuses the standard pattern and ensures fixes and improvements propagate to all subclasses automatically. |
|
BTND-07-010 |
MUST | If no suitable base class exists for the boilerplate pattern needed by a new BT leaf node, the implementer MUST extract the shared pattern into a new base class and document it for future reuse. Extracting the pattern prevents future copy-paste proliferation and makes the shared abstraction explicit and discoverable. |
Positive-Precondition Condition Node Naming
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-08-001 |
SHOULD | Condition nodes used as type-dispatch guards in a Selector(Sequence(Condition, Action), AlwaysSuccess) composite SHOULD use positive names (e.g., IsFooEventNode) that return SUCCESS when the condition is true and FAILURE otherwise. Negative-guard names (e.g., IsNotFooEventNode) that return SUCCESS to skip an effect and FAILURE to trigger it are a readability anti-pattern and SHOULD NOT be introduced in new code.The Selector(IsNotFoo, ApplyFoo) pattern requires readers to mentally invert the condition to understand when the effect fires, and the IsNotFoo naming implies a guard against Foo when the intent is to apply Foo. Positive-precondition Sequences make intent immediately readable: "if this IS a remove-embargo entry, apply embargo teardown; otherwise skip". |
Refines: BT-06-004 |
BTND-08-002 |
SHOULD | The AlwaysSuccess fallback in a Selector(Sequence(IsFoo, ApplyFoo), AlwaysSuccess) composite SHOULD be given a descriptive name of the form "<Effect>Skipped" (e.g., "EmbargoEffectsSkipped") so the no-op path is identifiable in BT visualizations and logs.An unnamed or generically-named AlwaysSuccess node is invisible in tree dumps and log output, making it harder to trace which effect was skipped and why. A descriptive <Effect>Skipped name makes the no-op path explicit and consistent with the existing codebase convention (e.g., "AutoCloseSkippedNotCaseManager", "ValidationSkipped"). |
Refines: BTND-08-001 |
BT Error Handling Contract
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-09-001 |
MUST | BT helper functions MUST raise exceptions on error — they MUST NOT catch exceptions and return a sentinel value. The update() method of the owning leaf node is the sole try/except boundary: it calls helpers, catches any exceptions they raise, writes to the error result channel (BTND-03-006), and returns FAILURE.If helpers catch their own errors and return sentinels (e.g., None, False), the leaf's update() method cannot distinguish a legitimate None return from a hidden failure. All error information travels via Python's exception mechanism so that the single boundary point (update()) has complete diagnostic context. |
Implements: BTND-03-006 Implements: EH-02-001 |
BT Status-Write Node Transition Validation
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTND-10-006 |
MUST NOT | Report-scoped RM state recorded before a case exists MUST NOT be persisted as a ParticipantStatus. It MUST be recorded on the report's ReportCaseLink.RM state begins at report receipt, and the case may never exist: a receiver may declare a bare report INVALID or CLOSED and never propose a case. That state was held in a standalone ParticipantStatus keyed on (actor, report, rm_state) and used as an existence latch, which gave the type two jobs and therefore made a single writer impossible. The two jobs were also already entangled: _build_owner_initial_status() reused the latch's id as the participant's first ladder rung, and _get_or_create_accepted_status() mutated the stored record in place. VultronReportCaseLink already exists for precisely this window (ADR-0041) and is the report-scoped record, so relocating the state there leaves ParticipantStatus ladder-only — and leaves the writer always holding a case, which is why no further case-absence disposition (ADR-0087) is required. |
Depends on: BTND-10-002 |
Behavior Tree Composability
Requirements for implementing Vultron's behavioral logic as composable, reusable BT structures using the simulator as the canonical blueprint. Covers the workflow of BT composition: how to use the simulator reference, where structures must live, when to use BT composite idioms instead of procedural code, and the fractal composability principle that applies at every depth.
File ID: BTC
Simulator Reference Workflow
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTC-01-001 |
MUST | Before implementing any BT node or subtree in vultron/core/behaviors/, a developer MUST search notes/vultron-bt.txt to locate the corresponding node in the canonical simulation tree.The canonical tree is the normative definition of Vultron's domain behavior. Implementing without consulting it risks inventing structures that contradict the protocol design. |
Refines: BT-06-002 |
BTC-01-002 |
MUST | After locating a node in notes/vultron-bt.txt, a developer MUST examine the corresponding class in vultron/bt/ to understand the node's children, structure, and composites. |
Refines: BT-06-002 |
BT Structure Pre-Definition
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTC-02-001 |
MUST | All BT composite structures used by use cases in vultron/core/use_cases/ MUST be pre-defined as named classes or factory composites in vultron/core/behaviors/ before use.Business logic lives in core/behaviors/. If use cases construct trees ad-hoc, the business logic migrates into use cases and becomes invisible to analysis of the behavior layer. |
Refines: BT-06-001 |
BTC-02-002 |
MUST NOT | Use cases MUST NOT construct ad-hoc BT composite trees inline. Inline construction means calling py_trees.composites.Sequence(children=[ NodeA(), NodeB(), ...]) or equivalent directly inside a use case's execute() method. |
Refines: BTC-02-001 |
BTC-02-003 |
MUST | Use cases MUST follow the instantiate-then-invoke pattern when using BTs: (1) import the pre-defined BT class from vultron/core/behaviors/, (2) instantiate it to obtain a root node, (3) wrap it in a py_trees.trees.BehaviourTree, and (4) call tick_once() or equivalent to execute it. |
Refines: BTC-02-002 |
BT Idioms Over Procedural Code
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTC-03-001 |
MUST | Behavioral logic that belongs inside a BT MUST be expressed using BT composite idioms (Sequence, Selector/Fallback, Decorator) rather than procedural if/else chains.BT idioms make logic auditable, extractable, and explainable by anyone who can read the tree structure, without reading implementation code. Procedural logic inside update() is opaque. See also notes/use-case-behavior-trees.md section "The Tree Is the Documentation" for the design invariant that gives this rule its purpose. |
Refines: BT-06-001 |
BTC-03-002 |
MUST | Precondition-then-action logic MUST be modeled as a Sequence where leading children are Condition nodes and trailing children are Action nodes. | Refines: BTC-03-001 |
BTC-03-003 |
MUST | Try-A-or-else-B fallback logic MUST be modeled as a Selector (Fallback) composite with A and B as children. | Refines: BTC-03-001 |
BTC-03-004 |
SHOULD | When a leaf node in the canonical simulator BT requires complex implementation logic, that logic SHOULD be expanded into a subtree using BT composite idioms rather than procedural code in a single update() method. |
Refines: BT-06-004 |
Fractal Composability
| ID | Priority | Requirement | Related |
|---|---|---|---|
BTC-04-001 |
MUST | The compose-and-reuse principle MUST apply at every depth level of the behavior tree — from single-purpose leaf nodes up to use-case entry trees. | Refines: BTND-02-002 |
BTC-04-002 |
MUST | When the canonical simulation tree shows the same named behavior appearing at two or more locations, that behavior MUST be implemented as a single reusable class in vultron/core/behaviors/, instantiated separately at each use site. |
Refines: BTND-02-001 |
BTC-04-003 |
MUST | Reusable protocol behaviors MUST be implemented as BT subtrees in core/behaviors/ and reused across all call sites; inline duplication of equivalent logic MUST NOT occur. The extraction depth SHOULD be the deepest level at which more than one call site requires the same subtree — not forced uniformly to the top level. | Refines: BT-06-002 |
BTC-04-004 |
SHOULD | Every pre-defined BT class in vultron/core/behaviors/ SHOULD be usable both as a standalone use-case entry point AND as a composable child of a larger BT. |
Refines: BTND-02-002 |
Build Workflow Learnings Policy
Defines the content policy for the per-entry incoming learnings queue used by the build and bugfix skills to pass implementation observations upstream to the learn skill. Covers what belongs in the queue, what must not go there, file naming and format, how the learn skill processes and archives entries, and the learning history entry type. The queue is narrow by design: findings with a definite owner are routed to a GitHub issue or an in-session fix at the moment of discovery, and only unverified general claims and spec divergences are queued for batch processing (BW-07).
File ID: BW
Incoming Learnings File Format
| ID | Priority | Requirement | Related |
|---|---|---|---|
BW-02-006 |
MUST | The learnings-index entry point MUST render one line per file in plan/incoming/learnings/ carrying its source, title, and filename, and MUST still print the valid entries when a file fails validation, reporting the failures on stderr.orient-agent read the directory whole at 42.7 KB of a budget meant for maps. Each title states its lesson in full, so the index carries what the directory says in 2.5 KB. It has to survive a malformed file because the queue is written by hand and an unquoted colon in a title: is the recurring failure — losing the whole index to one bad file would cost orientation the other nine entries. |
Depends on: BW-02-001 |
learning History Entry Type
| ID | Priority | Requirement | Related |
|---|---|---|---|
BW-04-001 |
MUST | The learning entry type MUST exist in HistoryEntryType in vultron/metadata/history/types.py. |
Refines: HM-02-002 |
BW-04-002 |
MUST | History entries of type learning MUST follow the same frontmatter and body format as all other append-history entry types, including required fields title, type, timestamp, and source. |
Depends on: HM-02-001 |
append-history --from-file Behaviour
Case Bootstrap Trust
Requirements for establishing trust in a VulnerabilityCase and its CASE_MANAGER at non-owner sites. Governs the bootstrap process by which a non-owner participant verifies that an incoming case and its managing actor are legitimate before accepting case state updates.
File ID: CBT
Original Report-Submission Bootstrap
| ID | Priority | Requirement | Related |
|---|---|---|---|
CBT-01-012 |
MUST | This codebase MUST carry the bootstrap VulnerabilityCase inline in the Create(VulnerabilityCase), with every nested domain object (e.g., each CaseParticipant in case_participants) embedded as a full inline object; it MUST NOT use a bare URI string for a nested object in this activity.How this codebase meets CBT-01-002 and CBT-01-007 (split per MS-12-010). Inlining is a shortcut of this prototype, which does not yet dereference (AKM-05-003), not a protocol rule. ActivityStreamRef accepts bare URI strings, but receiving handlers persist only materialized entries, so a string participant is silently skipped and later participant guards (e.g., SenderIsActiveParticipantNode) fail. |
Implements: CBT-01-002 Implements: CBT-01-007 |
Out-of-Order Handling and Replay Requests
| ID | Priority | Requirement | Related |
|---|---|---|---|
CBT-03-004 |
SHOULD | When bootstrap has not arrived in time, the receiver SHOULD request a replay from the original report receiver / case creator by sending an as_Question activity (vultron.wire.as2.vocab.base.objects.activities.intransitive.as_Question) asking for the bootstrap Create(VulnerabilityCase) to be resent. The target of the as_Question SHOULD be set to the case_actor_id recorded on the VultronPendingCaseInbox entry. |
|
CBT-03-005 |
MUST | This codebase's pre-bootstrap queue (CBT-03-001, CBT-03-003) MUST expire after 300 seconds by default, configurable via AppConfig.pre_bootstrap_queue_timeout_seconds (environment variable VULTRON_PRE_BOOTSTRAP_QUEUE_TIMEOUT_SECONDS).A queue with no expiry holds activities for a case that never bootstraps for as long as the process runs. A fixed default gives every deployment the same bound, and the override lets an operator fit it to how slow its peers are. |
Implements: CBT-03-001 Implements: CBT-03-003 |
CBT-03-006 |
MUST | When this codebase drops an expired pre-bootstrap queue (CBT-03-003), it MUST log a warning. An expired queue drops activities the actor received but never processed. Without a log record, an operator cannot tell that a case missed its bootstrap or which activities were lost. |
Implements: CBT-03-003 |
Verification
| ID | Priority | Requirement | Related |
|---|---|---|---|
CBT-05-001 |
MUST | Tests MUST verify that the original report submitter accepts CASE_MANAGER-originated Announce(VulnerabilityCase) updates only after a valid creator-signed bootstrap Create(VulnerabilityCase) has established trust. |
|
CBT-05-002 |
MUST | Tests MUST verify that a bootstrap Create(VulnerabilityCase) is rejected when the sender does not match the actor that received the report or when the case does not reference the known submitted report. |
|
CBT-05-003 |
MUST | Tests MUST verify that pre-bootstrap CASE_MANAGER messages remain unapplied, expire safely, and lead to a replay request to the report receiver. | |
CBT-05-004 |
MUST | Tests MUST verify that late joiners establish trust through InviteActorToCase and may then accept CASE_MANAGER-originated Announce(VulnerabilityCase) updates without an intermediate Create(VulnerabilityCase) bootstrap. |
|
CBT-05-005 |
MUST | Tests MUST verify that a valid bootstrap Create(VulnerabilityCase) causes the reporter's participant status record to be stored as an embedded participant in the new case, making the reporter addressable without a separate InviteActorToCase. |
|
CBT-05-006 |
MUST | Tests MUST verify that AddParticipantStatusBT executes successfully on the reporter's replica after bootstrap, confirming the reporter appears in the CASE_MANAGER's participant index. |
|
CBT-05-007 |
MUST | Tests MUST verify that the reporter's RM state is advanced from RM.START to RM.ACCEPTED as part of the bootstrap sequence, confirming full case-management relationship establishment. |
|
CBT-05-009 |
MUST | This codebase MUST treat a participant that arrives in a bootstrap Create(VulnerabilityCase) as a bare URI reference as absent, and so MUST reject the bootstrap under CBT-05-008.How this codebase meets CBT-05-008; split from it so CBT-05-008 states only what other participants observe (MS-12-010). This codebase does not yet dereference (AKM-05-003), so it cannot read a participant record named only by URI. Refusing it is a shortcut of this prototype reference implementation, not a protocol rule: an implementation that dereferences the reference has the participant data and need not reject. |
Implements: CBT-05-008 |
Case Ledger Processing
Requirements for participant assertions, CASE_MANAGER canonicalization, local case audit ledgering, recorded-history projection, and replication-facing CaseLedgerEntry objects. Covers the commit authorization model, per-case genesis hash derivation, the canonical entry criteria that separate the case ledger from the process log, the CASE_MANAGER inbox routing path that enables ledger entry authorship, and the causal-ordering model that makes log_index order the authoritative causal sequence.
File ID: CLP
Canonical CaseLedgerEntry
| ID | Priority | Requirement | Related |
|---|---|---|---|
CLP-02-010 |
SHOULD | In single-node deployments, the term field of a CaseLedgerEntry SHOULD be omitted or set to null.A single-node deployment has no Raft cluster; populating term in that context adds noise without meaning. |
|
CLP-02-011 |
MUST | In multi-node CASE_MANAGER cluster deployments, the term field of a CaseLedgerEntry MUST be populated with the current Raft term at time of append.The Raft term anchors each ledger entry to the leader epoch in which it was written, enabling followers to detect stale leader writes during leader-change scenarios. |
|
CLP-02-015 |
MUST | This codebase's CaseLedgerEntry and HashChainLedgerRecord MUST name their semantic-type field semantic_type (CLP-02-014). A stored or received entry that still carries the retired key eventType MUST be refused with a message naming the rename, never read through an alias.Received events already carry the same value as VultronEvent.semantic_type, so one name serves both sides. Stored data has no backwards-compatibility requirement, so the old key is refused, not translated (#4128, #4734). |
Implements: CLP-02-014 |
Canonical Ledger and Replication
| ID | Priority | Requirement | Related |
|---|---|---|---|
CLP-04-008 |
MUST | This codebase MUST surface through structured logging each refusal (CLP-04-007), including a received CaseLedgerEntry whose hash its content does not reproduce (SYNC-03-006) and a case copy that conflicts with the replica's chain (SYNC-16-006), both at ERROR, and each received processing fault reporting a failure to apply a CaseLedgerEntry (SYNC-16-002). Its freshness-gate and tail-reconstruction helpers (is_ledger_fresh_for_case, reconstruct_tail_hash) read the ledger directly and apply no disposition filter.A refusal leaves no ledger entry (CLP-04-007), so the log is the only record that it happened. A hash refusal or a conflicting case copy means a replica and the CASE_MANAGER disagree, which needs an operator, so it is logged at ERROR. The freshness and tail helpers must read the ledger as stored, because a filter there could hide an entry the chain depends on. |
Implements: CLP-04-007 Implements: SYNC-03-006 Implements: SYNC-16-002 Implements: SYNC-16-006 |
Pending Assertion Suppression
Canonical Entry Criteria and Ledger/Process-Log Separation
| ID | Priority | Requirement | Related |
|---|---|---|---|
CLP-07-016 |
MUST | This codebase MUST run an act's commits and every later node of the act's tree in one DataLayer transaction (CLP-07-015): for a received act, the guarded commit of the received activity runs in the same transaction as the consequences the act's tree commits after it. A received tree MAY commit outside that transaction only by naming a registered, reasoned exemption. For an embargo act, it MUST trial-apply every entry before the first commit. One act as the default of every received tree, not an opt-in, keeps a new tree from dropping it silently (#4983). |
Implements: CLP-07-015 |
CLP-07-005 |
SHOULD | The CASE_MANAGER commit boundary SHOULD enforce CLP-07-001 through CLP-07-004 with a runtime guard that rejects non-canonical entries before they enter the hash chain, failing fast rather than silently polluting replicated history The guard's canonical set ( _CANONICAL_PAYLOAD_SIGNATURES) is the list of (activity_type, object_type) pairs that are CM-accepted protocol-significant assertions (CLP-07-001). It MUST include every activity a CASE_MANAGER legitimately ledgers; a signature omitted from it turns a valid assertion into a rejected disposition. Offer(CaseParticipantRole) (ADR-0039) is such an assertion — the CASE_MANAGER records a received role-delegation offer — and was added to the set after #3764 found every role offer reaching a CASE_MANAGER refused as non-canonical. |
|
CLP-07-009 |
MUST | A payloadSnapshot — and every object carried inline in one (CLP-07-006) — MUST be in the wire (AS2) shape, such that the wire vocabulary class for its type_ can revalidate it without translation. When the object being captured is a core domain object, the wire shape MUST be produced through the core→wire rendering port (ARCH-20-001), never by dumping the core object with by_alias=True or by rewriting dumped keys in core. A received activity's snapshot is no such capture. It is the received body itself (CLP-07-011), already in the wire shape, and nothing renders it.CLP-07-011 requires the snapshot to be reconstitutable by receivers, and CLP-01-003/CLP-01-004 make it the replication substrate. Two distinct failure modes existed: core objects with no alias_generator were inlined in snake_case (unreconstitutable), and ParticipantStatus was dumped with a core by_alias=True that cannot produce the wire's flat emConsentState, so core hand-patched the dict afterwards. Both are removed by routing through the rendering port. |
Refines: CLP-07-011 Depends on: ARCH-20-001 |
CLP-07-010 |
MUST NOT | Core snapshot-construction code MUST NOT special-case individual field names to reach the wire spelling (e.g. moving a nested rm.state to a flat rmState). Structural differences between the core and wire shapes are the responsibility of the rendering port (ARCH-20-001).A per-field patch in core is a partial reimplementation of the translator that drifts as either shape evolves, and it is invisible to the adapter layer's own round-trip tests. Each such patch also silently defines the canonical shape for exactly one type, leaving every other nested type unhandled. |
Refines: CLP-07-009 |
Commit Authorization and Coverage
| ID | Priority | Requirement | Related |
|---|---|---|---|
CLP-09-001 |
MUST | Every call site that invokes the canonical commit operation MUST reach it only through a role-gated composition that verifies, at the time of invocation, that the active actor holds CVDRole.CASE_MANAGER for the case; this MUST be a role-based check, not an identity comparison, even where the CASE_MANAGER and the role holder happen to coincide todayAn audit of CommitCaseLedgerEntryNode call sites (Epic #788, issue #1021) found that only one of roughly ten call sites guarded the commit, and it did so with an ad hoc Python-level identity check outside the BT rather than a role check inside it. The other call sites committed unconditionally. Stating this as one vague authorization line (see BT-10-004) was not specific enough to prevent the gap from accumulating call site by call site. |
|
CLP-09-002 |
MUST NOT | The canonical commit operation MUST NOT be invoked directly (bare, unguarded) from any production call site outside the guarded-commit composition itself. | |
CLP-09-003 |
MUST | Every protocol-significant use case or BT subtree (per BT-06-001) MUST reach a canonical commit for its act, and one more for each further change it makes to the case file (CLP-07-013); a coverage test MUST enumerate protocol-significant entries (e.g., everything reachable from use_case_map()) and assert each one reaches a commit, rather than relying on manual audit to discover missing commits. The test MUST exempt a use case only on a ground CLP-02-013 lists or by naming an open issue that tracks the missing commitvalidate_report, ack_report, and close_case produced no canonical ledger entry at all on main for an unknown span of time (issue #998/#1022). Dispatch completeness is already enforced structurally for use_case_map() (DR-02-002, UCORG-02-002); commit completeness had no equivalent structural check and was found only by a manual re-audit. |
Implements: CLP-02-013 |
CLP-09-004 |
MUST | When the same use-case class may be invoked with more than one receiving-actor identity for a single logical activity (e.g., a relayed copy delivered to a second participant), authorization for the commit MUST be evaluated per invocation against the active actor's role, and MUST NOT be assumed from the use-case class's identity or from the fact that earlier invocations were authorized The rule was found in the ack_report flow of the demo, which once invoked one use case with the case actor as receiver and again with the finder as a relay target; only the case-actor invocation could commit (RK no longer reaches the CASE_MANAGER, RMB-08-003). The same shape of bug produced the hash-chain fork in issue #923, and it recurs at every new dual-invocation call site unless authorization is re-evaluated per invocation. |
CASE_MANAGER Inbox Routing for Canonical Ledger Entry Authorship
Requirements for the end-to-end routing path that delivers protocol-significant activities to the CASE_MANAGER's inbox so the CASE_MANAGER can author canonical ledger entries. Covers outbound addressing rules, the rule that the CASE_MANAGER commits its own emissions in the emitting tree rather than mailing itself a copy (ADR-0109), and the pre-flight guard that prevents non-CASE_MANAGER invocations from committing ledger entries.
| ID | Priority | Requirement | Related |
|---|---|---|---|
CLP-10-002 |
MUST | A received-side use case MUST include a pre-flight guard that compares receiving_actor_id to case_actor_id before executing any canonical ledger commit operation; if receiving_actor_id != case_actor_id, the commit step MUST be skipped entirelyThe correct pattern (established in status.py's _commit_log_cascade_bt) is to commit only when the actor running the use case IS the CASE_MANAGER. When a non-CASE_MANAGER inbox receives the same activity (e.g., a relay copy to a finder), the use case runs but skips the commit. The CASE_MANAGER's own inbox delivery (via CLP-10-001) is responsible for the canonical write. |
|
CLP-10-003 |
MUST NOT | A received-side use case MUST NOT execute a BT or call the canonical commit operation with actor_id set to any actor other than receiving_actor_id; resolving a foreign case_actor_id and passing it to BTBridge.execute_with_setup from the wrong DataLayer context is an identity-spoofing violation and MUST NOT occurnote.py and embargo.py contained this exact violation before ADR-0021: they resolved the CASE_MANAGER ID from the DataLayer and ran the guarded-commit BT with actor_id=case_actor_id even when the active DataLayer belonged to the vendor actor. This produced outbox entries attributed to the wrong actor and caused hash-chain divergence. See AGENTS.md § "Received-Side Use Cases MUST NOT Spoof Another Actor's Identity" for the general rule; CLP-10-003 is the specific application to the canonical ledger commit path. |
|
CLP-10-023 |
MUST NOT | The announce_case_ledger_entry semantic type MUST NOT appear in EXPECTED_SEMANTIC_TYPES for the case-actor canonical log.How this codebase meets CLP-10-004; split from it so CLP-10-004 states only what other participants observe (MS-12-010). |
Implements: CLP-10-004 |
CLP-10-005 |
MUST | A received-side use case MUST run exactly one BT per inbox delivery. Its execute() method MUST do exactly three things: build one BT via a tree-factory function in vultron/core/behaviors/, call BTBridge.execute_with_setup() exactly once with actor_id=resolve_receiving_actor_id(self._dl, request.receiving_actor_id) to run it, and handle the result (log/extract output per the existing Procedural Glue Exception). A call that execute() reaches through any function or method defined under vultron/core/use_cases/, resolved transitively, counts as a call by execute(). Work that needs more than one tree, including a door check, a clock decision (CLP-10-025), a refusal, or an alternative branch, MUST be composed into the one tree by its factory; Python control flow in the use case MUST NOT choose between trees or run one after another. execute() MUST NOT perform any other domain-significant work — no direct DataLayer mutation, no second execute_with_setup() call, and no direct import or call of create_guarded_commit_case_ledger_entry_tree (or any other guarded-commit factory). A buffered ledger entry that a commit or a case seed makes appliable is its own delivery (SYNC-14-009), never a second run in this one.CLP-10-002/CLP-10-003 (ADR-0021) correctly require a pre-flight guard before committing, and correctly require the guard to compare against receiving_actor_id. An audit (issue #1036, broadened to also resolve issue #1047) found three distinct ways the single-tree intent was violated, all closed by the same rule: (1) embargo.py (3 use cases) and report.py/status.py (3 use cases) ran a genuine second, independently Python-gated execute_with_setup() call under a different actor identity for the commit — literal actor-switching, reproducing the "post-BT procedural cascade" anti-pattern (see notes/bt-integration.md) for the commit step specifically; in embargo.py's case the main tree already (uselessly) embeds the same guarded-commit branch under the wrong actor, so the first execution is a guaranteed no-op. (2) note.py and actor/case_manager_role.py (deleted, issue #2429) called the commit factory exactly once but preceded it with domain-significant procedural code (DataLayer mutation, broadcast, trigger-activity calls) for the main operation, outside any BT — execute() was not "build one tree, run it, handle the result" even though only one execute_with_setup() call existed. (3) case/lifecycle.py already executes a single tree under the correct actor but constructs it ad hoc inside execute() instead of via a behaviors/ factory function. See ADR-0022 for the full per-site breakdown. A later audit (ISSUE-4585) found the rule counted only calls written in execute() itself, so helpers ran door-check, expiry, refusal and late-answer trees beside the main one, with Python choosing between them: the cascade ADR-0022 rejected, moved one call down. Behavior trees exist to compose, so the rule follows helpers and names composition as the only way to combine trees (ADR-0135). |
|
CLP-10-007 |
MUST | Any operation that is procedural code in execute() (including the use case's main operation) MUST be expressed as a node in the single BT tree built by the tree-factory function. |
Refines: CLP-10-005 |
CLP-10-008 |
MUST | The guarded-commit subtree MUST be composed as a child of the tree-factory function, not invoked as a separate execute_with_setup() call. |
Refines: CLP-10-005 Refines: CLP-10-002 |
CLP-10-006 |
MUST | In any receive-side BT tree that includes a GuardedCommitCaseLedgerEntryBT subtree (composed via create_guarded_commit_case_ledger_entry_tree), the commit subtree MUST appear after the intake node, after the clock-decision stage (CLP-10-025), and after all pure precondition-guard nodes, and before any protocol-effect nodes. The intake node is the single shared node that archives the received activity as received (CLP-10-017). A precondition-guard node is one that only reads state and returns FAILURE on mismatch, making no writes. A protocol-effect node is any other node that writes to the DataLayer, mutates actor or participant state, or enqueues an outbox item; the intake node is not a protocol-effect node, and neither is a clock decision (CLP-10-025), which commits its own entry. A refusal effect (CLP-10-022) is a protocol-effect node that runs only when a precondition guard has refused, so the tree fails before the commit; it never runs on a delivery the guards accept, and so never precedes a commit.Receipt is a fact whether or not the assertion is later accepted, so the record of what arrived is written before anything judges it. Classing that write as a protocol effect forced it after the commit, and handlers that needed the record regardless of the verdict stored it procedurally in execute() instead (ISSUE-3339, ADR-0111). A refusal effect answers a refusal; the ordering rule exists so an admitted assertion's effects follow the ledger record of their cause, and a refused assertion has no ledger record and no other effect. |
|
CLP-10-010 |
MUST | In any receive-side BT tree that includes a GuardedCommitCaseLedgerEntryBT subtree, the ordering MUST be: (1) intake, (2) door guards, in order: the post-close write boundary (CM-23-013), the addressee check (HP-01-005) and the sender guard (HP-01-006), (3) clock decisions (CLP-10-025), (4) precondition guards, (5) commit, (6) all protocol effects. Clock decisions are the only commits before stage (5), other than a diversion (CLP-10-026), which answers its delivery in place of stages (5) and (6). Refusal effects (CLP-10-022) belong to stage (4): they run only after a precondition guard refuses, and the tree then ends without reaching (5) or (6).Intake records what arrived. The canonical case ledger records the cause (the received activity the CASE_MANAGER accepted) and replicates it to all participants, so cause precedes effects. A clock decision is the CASE_MANAGER's own act, ledgered even when the activity is then admitted (CM-21-014), so it runs before the guards that read it, and after the door guards so an unentitled sender never makes it write (ADR-0135). |
|
CLP-10-011 |
MUST | A receive-side BT tree that applies any protocol effect before committing the triggering activity inverts causal ordering and MUST be corrected. The canonical case ledger records the cause (the received activity) and replicates it to all participants. If protocol effects (state transitions, outbox enqueues, participant updates) run before the ledger records the triggering activity, the case ledger may show effects without the causes that produced them. Participants replicating the ledger via Announce(CaseLedgerEntry) would observe an effect entry with no prior cause entry — making forensic and audit use of the ledger unreliable. Audit issue #1068 found this inverted ordering consistently across receive-side BTs in vultron/core/behaviors/ and vultron/core/use_cases/received/. The ledger entry records that the activity was received and accepted by the CASE_MANAGER — this is independent of whether subsequent effects succeed (a failed effect is an operational concern, not a reason to omit the received-activity record). |
|
CLP-10-009 |
MUST | In a receive-side BT composed via create_receive_activity_tree, every node that can return FAILURE to refuse an inbound assertion (a rejection validator) MUST appear in precondition_guards; a rejection validator placed in an effect stage (replica_effects or manager_effects) aborts the Sequence after GuardedCommit has already written a canonical ledger entry, producing canonical/replica divergence (ISSUE-2254). Feedback a refusal owes the sender is passed as refusal_effects (CLP-10-022), never as an emit placed beside the refusing guard. |
|
CLP-10-012 |
MUST NOT | A separate ValidateRMTransitionNode in an effect stage (replica_effects or manager_effects) MUST NOT be used in the receive-side path; CheckParticipantStatusAcceptableNode judges an inbound ParticipantStatus before the commit (RSH-09-001) and a redundant validator after the commit creates divergence.add_participant_status_tree previously contained ValidateRMTransitionNode after GuardedCommit, masked by a _rm_was_carried_forward() workaround. Both were corrected in ISSUE-2254. |
|
CLP-10-013 |
MUST | A receive-side tree that uses create_receive_activity_tree MUST pass case_id, so that a canonical ledger entry is committed for each admitted activity, exactly when the received activity's (type, object) pair is a canonical payload signature (_CANONICAL_PAYLOAD_SIGNATURES); a tree whose received activity has no canonical signature — the Announce(CaseLedgerEntry) replication envelope (CLP-10-004) — MUST pass case_id=None and omit the commit stage rather than commit an entry the canonical-entry check would refuse. A canonical signature is exempt from the commit and also passes case_id=None — Announce(VulnerabilityCase), which only the CASE_MANAGER sends and only a participant replica, never the CASE_MANAGER itself, receives (PCR-03-004). The bootstrap Create(VulnerabilityCase) a participant receives MUST likewise pass case_id=None, because only the CASE_MANAGER sends it, and the case's genesis entry is committed where the CASE_MANAGER creates the case (CLP-02-013).Amended with ADR-0111: the rule as first written required a commit for every factory-built tree, which the CASE_MANAGER refused for an Update(VulnerabilityCase) the moment the update tree moved onto the factory (#3870). ADR-0111 (#3936) recorded that an owner's Update(VulnerabilityCase) is not a ledgered assertion: no production code sends it, it has no protocol story, and Announce(CaseLedgerEntry) fan-out already carries every ledgered case change (ADR-0108). Its received path then changed the case with no entry, which CLP-07-013 forbids, so it was removed (#4443). The Announce(VulnerabilityCase) receiver is never the CASE_MANAGER, so a case_id would only make the commit gate skip (#4304). Add(CaseParticipant) was exempt until #4081 made it the Case Owner's reinstatement request: its commit landed together with its replica apply node (ApplyReinstateCaseParticipantFromLedgerNode, RSH-08-004), so it now commits. The Reject(Offer) and TentativeReject(Offer) trees carry canonical signatures their CASE_MANAGER receivers ledger, followed by the status each verdict writes as its own entry (CLP-07-013), so they commit. Offer(CaseParticipantRole) (ADR-0039) was missing from _CANONICAL_PAYLOAD_SIGNATURES, so the role-offer received tree — which passes case_id and gates a commit on the CASE_MANAGER — had every commit refused as non-canonical and the handler reported rejected (#3764). It is now canonical: the CASE_MANAGER records the role-delegation offer as a CM-admitted assertion (CLP-07-001), with no replica state effect — an offer is a proposal, not the grant, exactly like its sibling Offer(CaseParticipant) (CM-16). Whether accepting a role offer should itself mutate a participant's roles on every replica (an accept_case_participant_role replay node) is a separate, unspecified question tracked by #4404. Original rationale: a rejection validator (FAILURE-on-mismatch, read-only) belongs before the commit; an effects node (writes, enqueues, state mutations) belongs after. Mixing the two — placing a rejection validator in an effect stage — creates a window where the ledger records receipt of an activity the local actor then refuses to apply, violating the audit guarantee that every ledger entry corresponds to a locally applied protocol event. add_participant_status_tree previously contained ValidateRMTransitionNode after GuardedCommit, masked by a _rm_was_carried_forward() workaround. add_case_status_tree previously used a plain py_trees.Sequence with no commit at all. Both were corrected in ISSUE-2254. |
Refines: CLP-10-006 |
CLP-10-014 |
MUST | A store MUST NOT mint a canonical case-ledger index unless it is the store that holds that case's canonical log. CommitLogEntryBT MUST fail — raising, having written nothing — when the actor it executes as is not one the running container hosts, because no sanctioned path executes a tree in a store other than its actor's own (BT-05-005, ADR-0109).A log index is a claim on a position in one hash chain, so two stores claiming the same position fork it irreparably. Before ADR-0109, the delegated-emit path was how a second claimant arose: a trigger the case owner ran emitted as the case's CASE_MANAGER (PCR-08-007, CM-24-001) and committed under that same actor id. When the owner hosted the CaseActor that was the canonical commit. After an ownership handoff it was not — the CaseActor remained on the container that created the case while the owner was elsewhere — and BT-05-005 carried an exception that left the tree running in the requester's store so the activity was created and outboxed where it could be delivered. The commit rode along on that exception and minted index N in the owner's replica while the real CASE_MANAGER minted its own index N from a cc: copy of the activity. The two disagreed on bytes, not just timing: the owner's snapshot carried local enrichment (attributedTo, name, the case's caseStatus) that the wire copy did not (CM-17-002). Both then fanned out, and the case partitioned into the participants each reached first (#2626). The guard first answered this by declining silently and relying on the cc: copy. ADR-0109 removed both the exception and the copy: a participant on another container asks the CASE_MANAGER to act by message (CM-24-004), so a tree executing as an actor its store does not host is a programming error, and the guard raises. |
Refines: BT-05-006 Refines: CLP-10-001 |
CLP-10-024 |
MUST | A received-side use case MUST NOT use resolve_receiving_actor_id() as the subject a received message names. resolve_receiving_actor_id() is legitimate only as the actor_id passed to execute_with_setup() (CLP-10-005), that is, whose store runs the tree.How this codebase meets CLP-10-015; split from it so CLP-10-015 states only what other participants observe (MS-12-010). |
Implements: CLP-10-015 |
CLP-10-017 |
MUST | Every receive-side BT tree MUST begin with the shared intake node that create_receive_activity_tree supplies. The intake node MUST idempotently archive the received activity exactly as received, as a ReceivedActivityRecord that holds the received body as the parser sealed it (VM-08-002) and nothing derived from it, whose id the receiver derives (ReceivedActivityRecord.build_id) and which carries the sender's activity id for the reverse lookup. An activity that reaches intake with no received body and nothing held for it MUST fail intake as a wiring fault (ARCH-15-001) and archive nothing. It MUST NOT store the activity under the id the sender chose, MUST NOT write a core record from any object inlined in it, and MUST perform no other write. A core record a received object gives rise to (a note, a report, a status, an embargo event, a case replica) is written by an effect node from the copy the event carries, after the precondition guards.One node, composed once by the shared factory, replaces the handler-local store helpers that received-side use cases called from execute() — the files KNOWN_VIOLATIONS enumerates once #3869 widens the ratchet (ISSUE-3339). The archive is the mail, not core's records: an object inlined in an activity is a message shaped like that object, and writing it as a record before the guards would let any sender seed a case replica or a ledger entry ahead of the trust and chain checks (PCR-03-004, CBT-01-005) — a stored case row is the replica. The archive is the sealed received body itself (ADR-0107, ADR-0149): a stored summary of it drops what core does not name (cc, an RSVP deadline, a peer's own keys) and drifts from the extractor, so every reader loads the body again. The row is keyed by the receiver because the DataLayer is one id-keyed table per actor: an activity stored under the id its sender chose would let a sender who names its activity after a record the receiver derives (a pending-case-inbox marker, an offer record) occupy that id ahead of the receiver's own write, so the later create() of the real record fails as a duplicate and a read-then-create helper reads the squatter as "already stored". A key the receiver mints is the receiver's to manage; the sender's id is data on the row, not the row's address. |
Refines: CLP-10-005 |
CLP-10-018 |
MUST | A refused inbound assertion MUST leave the received activity archived by intake in the receiver's DataLayer; a refusal MUST NOT remove or alter the archive. Receipt is a fact and refusal is a verdict on the assertion, not on the record of receipt. The refusal itself belongs in the process log, never the ledger (CLP-05-002). A receiver that declines to create a case and still keeps the report and the Offer (CM-15-002) does so as an effect of that policy branch, from the event's copy, not because intake stored them. |
Refines: CLP-01-001 |
CLP-10-019 |
MUST NOT | A received-side use case MUST NOT store the received activity through any path other than the intake node — not through a handler-local helper, a per-tree store node, or a call in execute() — and MUST NOT write a core record from a received object anywhere but an effect node of its tree.The store-what-arrived step was implemented separately across the received handlers KNOWN_VIOLATIONS enumerates (#3869) and one shared use-case helper, each invisible to the mutation ratchet because the write sat in a helper rather than in execute() itself (ISSUE-3339). One shared node is the DRY form (CS-22-001) and the only one the ratchet can see. The core records those helpers also wrote move to effect nodes, where the guards have already run (ADR-0111 as amended). |
Refines: CLP-10-005 |
CLP-10-021 |
MUST | A received-side use case that tests whether a message is addressed to the receiving actor MUST compare actor ids with a trailing-slash-tolerant comparison (is_addressed_to() or same_actor_id() in vultron/core/predicates/addressing.py), never an exact string match.to: and cc: arrive as the sender wrote them while the receiving actor is canonical, so an exact match misses a recipient spelled with a trailing slash and refuses mail that was addressed to this actor. Offer(Report) is genuinely several-recipient and reads its eligibility from to: (HP-09-001). Source: ISSUE-2667. |
Refines: HP-09-001 |
CLP-10-022 |
MUST | create_receive_activity_tree MUST itself add the refusal notice (CLP-05-001) as the first refusal effect of every tree that commits to a case and has a precondition guard, unless the tree names a registered exemption that says what answers its refusals instead. A receive-side tree that owes the sender further feedback when a precondition guard refuses its assertion MUST pass that feedback to the factory as refusal_effects. The factory MUST run the refusal effects only when a precondition guard refuses, never when the sender guard refuses or the guards pass, and never when a guard's failure is an idempotent repeat the handler reports SKIPPED; MUST run them only at the case's CASE_MANAGER; MUST NOT run them again for a redelivery that intake found already archived; MUST run each refusal effect whether or not an earlier one failed; and MUST leave the tree failing with the guard's refusal, so nothing of the refused assertion is committed and the handler reports REFUSED for the guard's reason. Every refusal effect MUST be emit-capable and MUST contain no state-write node: a refusal commits nothing of the assertion, so it changes no state the ledger does not record. The one exception is the CASE_MANAGER's own note (_CommitCaseManagerNoteBase, RSH-06-004), which commits itself as its own add_note_to_case entry before it attaches the note to the case.A refusal the receiver can answer in the moment should be answered, such as a backward RM declaration (RSH-06-004). An emit beside the guard would precede the commit on an admitted delivery (CLP-10-006); per-tree copies would repeat the structure (CS-22-001). Only the CASE_MANAGER adjudicates and answers (BT-17-008); an unentitled sender is owed no answer (HP-01-006), nor is a redelivery; a repeat whose effect is in place was not refused, so a Reject of it would contradict that effect. |
Refines: CLP-10-006 Refines: CLP-10-010 |
CLP-10-025 |
MUST | A receive-side tree in which the CASE_MANAGER must record a decision it takes from its own clock or local policy before it judges the received activity, such as an invitation expiry (CLP-07-013, CM-28-009), MUST pass that decision to create_receive_activity_tree as a clock decision. The factory MUST run the clock decisions after the door guards and before the precondition guards (CLP-10-010), and only at the case's CASE_MANAGER (BT-17-001). A decision about the case itself, the embargo end-time check (EMB-13-003), MUST be a clock decision of every receive-side tree that names a case and has a sender guard and the post-close boundary, with no argument from the tree; a tree still exempt from a sender check takes it once it declares its sender rule (HP-01-007). A clock decision MUST commit only CASE_MANAGER-authored entries, one for each AS2 activity needed to express what it decided, and the case-file changes those entries record, and nothing about the received activity: an expiry is one entry, and an end-time termination is the Remove(EmbargoEvent) and each status update and re-issued stub Invite it needs. Its entries MUST be ordered so that no prefix of them leaves the embargo register showing an entry TERMINATED while another is still PROPOSED (EP-08-004, ADR-0122); the Remove carries the whole register step, the cancellation of every open proposal included. It MUST decide from the CASE_MANAGER's clock, its local policy and the case's ledger and file; the received activity MAY prompt the decision and name what it concerns, but MUST NOT be its evidence; the entry MAY carry the activity's claimed published time as received (CLP-15-003), which records when the activity claims to have been sent and decides nothing. A clock decision with nothing to record (no deadline has passed) MUST succeed so the tree continues. A clock decision whose commit the ledger refuses MUST log the refusal at WARNING and MUST NOT end the tree: the precondition guards still refuse what the unrecorded decision forbids (EMB-17-001). A decision that changes nothing in the case file, such as an ownership-transfer Offer's expiry, records nothing and is not a clock decision (CM-21-017, ADR-0152). An internal error in a clock decision MUST end the tree as an internal error (ADR-0095), never be absorbed as a refusal.An expiry must be ledgered even when the activity that revealed it is then admitted, so it cannot be a refusal effect, and it must not wait for the activity's commit. Running it as a separate tree before the received tree broke the one-tree rule (CLP-10-005); a named stage keeps it in the one tree and limits it so the stage cannot become a way to commit the activity's own effects before the guards (ISSUE-4585, ADR-0135). A case ledger entry is one AS2 activity and its object, and the CASE_MANAGER does only what AS2 activities express, ledgering each, so a decision that takes several activities commits several entries; the limit is on whose act they record, not on their count (ISSUE-4641). The end-time check ran as a dispatcher hook ahead of every tree, so any sender naming the case could provoke the termination; as a clock decision it runs only after the sender guard. |
Refines: CLP-10-010 Refines: CLP-07-013 |
CLP-10-026 |
MUST | A receive-side tree some of whose deliveries are answered by an act of the receiver's own instead of committing the received activity, such as the CASE_MANAGER's answer to a late embargo Accept (EMB-17) or a receiver's ER once P/X/A is set (EMB-01-002, EMB-02-002), MUST pass each such answer to create_receive_activity_tree as a diversion among its precondition guards, so the tree, not the use case, chooses the answer (CLP-10-005). A diversion MUST have a read-only condition; when the condition holds, the diversion's effects MUST answer the delivery and the tree MUST then end before the received activity's commit, so that commit and the admitted path's effects never run for it. A diversion MUST NOT commit the received activity. A diversion that commits or writes case state MUST run only at the case's CASE_MANAGER (BT-17-001) and MUST commit only its own CASE_MANAGER-authored entry and the change that entry records. A diversion that answers as the receiver wherever the door guards admitted the delivery MUST write no case or participant state (RSH-08-003) — it MAY store the activity it answers so its answer can be built from it — and every emit in it MUST be covered by a named exemption (BT-17-008). A diverted delivery has already been answered, so the factory MUST run no refusal effect for it (CLP-10-022); a tree with diversions still answers the refusals its guards make with the refusal notice (CLP-02-005).The late-Accept routing and the P/X/A refusal were alternative trees the use case chose and ran after or before the main one, which the one-tree rule forbids (ADR-0135). Neither fits a refusal effect: the late-Accept answers commit and write consent, and the P/X/A ER is owed by a participant too and is idempotent by its own sealed record (ID-04-005), not by the archive. A diversion keeps each answer in the one tree, where the ratchets and a reader can see it (ISSUE-4642). |
Refines: CLP-10-005 Refines: CLP-10-010 |
CLP-10-027 |
MUST | In this codebase, every protocol-significant trigger tree MUST emit at least one outbound activity addressed to case_manager_id (CLP-10-001). A tree the CASE_MANAGER runs to originate an activity (e.g., invite_actor_to_case) MUST emit from the CASE_MANAGER's own store and commit the canonical entry in the emitting tree (CLP-10-013). A participant whose container does not host the CASE_MANAGER MUST NOT execute a tree locally as the CASE_MANAGER (CM-24-004).The CASE_MANAGER is the only writer of the case ledger, so an assertion that never reaches it is never committed and the act is lost. Running a tree as the CASE_MANAGER in a container that does not host it would write canonical state into a store no replica reads. |
Implements: CLP-10-001 |
Protocol Request/Reply State Detection
Requirements for querying whether a protocol-level request/reply pair (e.g., Offer(CaseParticipant) → Accept/Reject) is still open or has been closed. These rules establish the canonical pattern for duplicate-emission suppression and CASE_MANAGER decision-gate checks.
| ID | Priority | Requirement | Related |
|---|---|---|---|
CLP-11-002 |
MUST NOT | find_protocol_pair() (durable, DataLayer-backed) and PendingAssertionStore (ephemeral, in-memory suppression) MUST NOT be unified. They serve distinct purposes: find_protocol_pair determines canonical open/closed state from the ledger; PendingAssertionStore suppresses duplicate near-term re-emits while waiting for the CaseLedgerEntry round-trip. Merging them would conflate durable protocol state with short-lived emit-suppression state. |
|
CLP-11-003 |
MUST | ProtocolPair MUST be the shared value type for both find_protocol_pair() and PendingAssertionStore. Named reply-type constants (OFFER_CASE_PARTICIPANT_REPLY_TYPES, INVITE_ACTOR_TO_CASE_REPLY_TYPES) MUST be used when constructing or querying a ProtocolPair to avoid hard-coding reply semantic-type strings at call sites. |
|
CLP-11-004 |
SHOULD | When searching for a reply entry in find_protocol_pair(), the implementation SHOULD use the semantic_type field of CaseLedgerEntry (derived from MessageSemantics.value) as the primary key, scoped to case_id. in_reply_to chain-following is an internal implementation detail and MUST NOT be exposed as a public port primitive. |
|
CLP-11-005 |
MUST | This codebase MUST answer whether a request/reply pair has been authorized (CLP-11-001) through CasePersistence.find_protocol_pair(), which queries the case ledger.One query over the case ledger gives every caller the same answer to whether a request was already answered. Separate checks over other stored records can disagree with the ledger and with each other. |
Implements: CLP-11-001 |
CASE_MANAGER-Authored Signature Completeness
Requirements on the set of canonical-entry signatures the CASE_MANAGER is authorized to author (_CASE_AUTHORED_SIGNATURES in canonical_entry.py), ensuring all case-initialization operations are expressible without falling back to vendor authorship or back-fill workarounds.
| ID | Priority | Requirement | Related |
|---|---|---|---|
CLP-12-001 |
MUST | Add(CaseStatus) ledger entries authored by the CASE_MANAGER MUST be accepted as canonical entries by the ledger validation step.Issue #1767: ("Add", "CaseStatus") appeared in _CANONICAL_PAYLOAD_SIGNATURES but was absent from _CASE_AUTHORED_SIGNATURES, causing the ledger validator to reject CASE_MANAGER-authored add_case_status_to_case entries. Fix required by ADR-0041's native-initialization model (CM-22-003). |
Implements: CM-22-003 |
CLP-12-002 |
MUST | _CASE_AUTHORED_SIGNATURES MUST be a complete superset of all ("verb", "object-type") pairs emitted by the CASE_MANAGER during native case initialization (create_case, create_embargo_event, add_report_to_case, create_case_participant, add_participant_status_to_participant × N, add_case_status_to_case). Any pair present in _CANONICAL_PAYLOAD_SIGNATURES that the CASE_MANAGER natively authors MUST also appear in _CASE_AUTHORED_SIGNATURES.A CASE_MANAGER that cannot author its own initialization entries must resort to vendor-side workarounds (the pattern that ADR-0015 introduced and ADR-0041 supersedes). Maintaining completeness of the authorized signature set is therefore a prerequisite for the corrected CASE_MANAGER-authoritative model. |
Refines: CLP-12-001 |
Idempotency Guard Silence
Requirements ensuring that BT idempotency guards (duplicate-detection conditions that abort processing when an event has already been handled) do NOT write ledger entries of any disposition. The canonical case ledger records protocol assertions; internal guard no-ops are process-log content only (ADR-0019).
| ID | Priority | Requirement | Related |
|---|---|---|---|
CLP-13-001 |
MUST NOT | A BT idempotency guard that detects a duplicate and returns Status.FAILURE to abort processing MUST NOT write a CaseLedgerEntry of any disposition ("recorded" or "rejected").Concern #1754: when CheckInviteeNotAlreadyParticipantNode fired (actor already a participant), it caused a downstream disposition="rejected" ledger entry to be written. That entry appeared in demo reports as "Invited an actor to the case [rejected]" with no actor name and no state transition — a misleading non-event in the canonical audit trail. ADR-0019 states the ledger "should either record the event or stay silent." An already-handled duplicate is a guard no-op, not a protocol event; it belongs in the process log (Python logging), not in the replicated ledger. |
Refines: CLP-07-001 |
CLP-13-002 |
MUST | Idempotency guard behaviour MUST be encapsulated in a reusable SilentIdempotencyGuardMixin (or equivalent composable BT pattern) so that the silent-failure-on-duplicate invariant is expressed once and tested once, not re-implemented per call site.Concern #1754 audit revealed multiple ad-hoc duplicate-detection checks scattered across BT trees. Centralising the pattern prevents future call sites from inadvertently re-introducing ledger writes on guard no-ops. |
Implements: CLP-13-001 |
CLP-13-003 |
MUST | When an idempotency guard fires and returns Status.FAILURE, it MUST emit a Python logger.info or logger.debug message recording the duplicate detection. No other observable side effect (ledger write, outbox enqueue, DataLayer mutation) is permitted.Separating the positive logging obligation (CLP-13-003, priority: MUST) from the prohibition on ledger writes (CLP-13-001, priority: MUST_NOT) makes each requirement independently testable and visible to tooling that filters by priority level. |
Implements: CLP-13-001 |
CaseLedger Causal Ordering — CASE_MANAGER Timestamp Invariants
Normative requirements establishing that log_index order is the canonical causal order from the CASE_MANAGER's perspective, and the temporal invariants that conformance tests can verify against any ledger. See ADR-0079 for the decision record and rationale.
Case Management
Requirements for VulnerabilityCase lifecycle management, CASE_MANAGER responsibilities, and the RM/EM/CS/VFD state model. Covers actor isolation at the protocol level, the distinction between participant-specific and participant-agnostic state, case initialization sequencing, embargo acceptance ownership, and the invitation acceptance lifecycle.
File ID: CM
CASE_MANAGER Lifecycle
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-02-010 |
MAY | The actor enacting CASE_MANAGER and the case owner (named actor) MAY be the same actor. When they are different actors, each MUST have its own actor ID, even when co-located on the same server A deployment may enact both roles in one actor. When the proposal flow (CP-09-001) yields two actors, distinct identities keep every case-state transition on the wire protocol, whatever the topology, and let the two separate later without protocol changes. An actor holding both roles still routes case traffic through its CASE_MANAGER role (CBT-01-003). |
Refines: CM-01-001 Depends on: CM-02-001 |
CM-02-014 |
MUST | A VulnerabilityCase MUST have both a CVDRole.CASE_OWNER participant and a CVDRole.CASE_MANAGER participant from the moment of creation. Neither slot may be left empty or deferred to a later operation.An unoccupied CASE_OWNER or CASE_MANAGER slot makes role-guard checks undefined from the moment the case exists. Any creation path that omits either participant leaves the case in a state that cannot be self-healed without an explicit remediation step, and causes resolve_case_manager_id to return None immediately — making the role ledger unreliable as the single source of authority (ADR-0088, ARCH-24-001). |
Refines: CM-02-001 |
CM-02-015 |
MUST | When a VulnerabilityCase is created via the direct-mint path (e.g. trigger/create-case, without a Create(as_CaseProposal) round-trip), the creating actor MUST be registered as a CaseParticipant carrying both CVDRole.CASE_OWNER and CVDRole.CASE_MANAGER as part of the same atomic case-creation operation.In the direct-mint path there is no intermediary proposer; the creating actor is the sole authority holder from the start and holds both roles until it delegates CASE_MANAGER via an Offer(role) → Accept exchange. Deferring or omitting participant registration means the case roster is empty at birth, violating CM-02-014 and breaking any caller that queries the role ledger immediately after creation. |
Refines: CM-02-014 |
CM-02-016 |
MUST | When the CASE_MANAGER commits an accept_case_participant_role ledger entry (the acceptance of an Offer(CaseParticipantRole), ADR-0039), the offered CVDRole MUST be granted to the target Actor's CaseParticipant. Authority is the CASE_MANAGER ledger: the CASE_MANAGER applies the grant to its own replica when it commits the entry, and every other participant applies it by replaying that entry's Announce (RSH-08-004). A participant MUST NOT mutate case_roles from an Accept message directly.#3764 made ("Offer", "CaseParticipantRole") a canonical ledger signature, but an offer is a proposal, not the grant; before this requirement no received tree mutated the target's case_roles and the committed accept_case_participant_role entry had no replay slot, so the role was granted nowhere and no replica learned it. Keeping role authority with the CASE_MANAGER — applied inline on its own copy at commit and by ledger replay everywhere else — keeps every replica convergent (ADR-0108, ADR-0124), the same shape role-free participant and status grants already use. |
Refines: CM-02-015 |
Case State Model
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-03-006 |
MUST | Collection fields that hold status histories MUST be pluralized and documented as history collections |
Object Model Relationships
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-05-013 |
MUST | This codebase MUST model VulnerabilityReport, VulnerabilityCase, VulnerabilityRecord, CaseReference, CaseParticipant, and ParticipantStatus as separate types and MUST NOT substitute one for another at any API boundary or in the persistence layer. How this codebase meets CM-05-001; split from it so CM-05-001 states only what other participants observe (MS-12-010). The case actor is not in this list: it is an ordinary VultronService holding the CASE_MANAGER role, not a distinct type (#4605, #4612). |
Implements: CM-05-001 |
CM-05-014 |
SHOULD | This codebase SHOULD log a warning when a VulnerabilityCase closes with no associated VulnerabilityRecord (CM-05-003).How this codebase reports a miss of CM-05-003; split from it so CM-05-003 states only what the case carries (MS-12-010). A log line is not something another participant can observe. |
Implements: CM-05-003 |
CVD Action Rules API
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-07-001 |
SHOULD | The system SHOULD expose an endpoint that returns the set of valid actions available to a case participant given the current case state and their role | Refines: AR-07-001 Refines: AR-07-002 |
CM-07-002 |
MUST | The action rules response MUST include the participant's role and the current RM, EM, CS, and VFD states relevant to that participant | |
CM-07-003 |
MUST | The action rules response MUST list valid next actions as structured objects including the action name and any required parameters |
Domain Model Architecture
Redacted Case View
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-09-004 |
MAY | For the prototype, the stub Invite MAY carry the case stub of CM-11-013, which names the full case ID, deferring the redacted view to a later phase. |
Per-Participant Embargo Acceptance
Case Creation Timing
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-12-001 |
MUST | A VulnerabilityCase MUST be created when an Offer(Report) activity is received by the vendor actor |
|
CM-12-005 |
MUST | InvalidateReportReceivedUseCase, CloseReportReceivedUseCase, and ValidateReportReceivedUseCase MUST dereference the incoming report ID to the associated case ID before delegating to the corresponding case-level use case |
Depends on: CM-12-001 |
Embargo Acceptance Ownership
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-13-009 |
MUST | The embargo accept and reject use cases MUST succeed silently when the participant's consent row for the embargo is already AGREED (for accept) or DECLINED (for reject), without re-running state machine transitions or emitting invalid-transition warnings.How this codebase meets CM-13-005; split from it so CM-13-005 states only what other participants observe (MS-12-010). |
Implements: CM-13-005 |
CM-13-006 |
MUST | When a case is created with a default embargo that reaches EM.ACTIVE at creation time, the case owner MUST be initialized as a signatory in the same BT node or an immediately following sibling node in the same BT subtreeThe case creator is the case owner by default; seeding them as a signatory prevents the case owner from being locked out of their own active embargo until a separate accept step occurs. The consent state must be set via the PEC state machine (not by direct field assignment) to preserve state machine invariants. |
Depends on: CM-12-004 |
CM-13-007 |
MUST | A received Add(Note) MUST be treated as an idempotent redelivery only when the case's ledger already records that same Add, and the attach step MUST test whether the note's id_ is present in VulnerabilityCase.notes. Neither MUST be decided by whether the note object exists in the DataLayer: a stored note under the id refuses the Add (HP-08-006).A note stored under an id is not evidence that this Add attached it to this case: it may be another case's note, or another Add's. Treating its existence as idempotency let an Add naming another case's note pass (#4831). |
Depends on: HP-08-006 |
Case Creation Policy
Requirements for the configurable auto_create_case policy on ActorConfig, which controls whether a VulnerabilityCase is created automatically upon receipt of an Offer(Report).
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-15-001 |
MUST | ActorConfig MUST expose an auto_create_case boolean field with a default value of True.Receivers that acknowledge a report (send Read(Offer(Report)), VAM-02-003) before deciding to accept or reject it require the ability to delay case creation. The default (True) starts a case at every Offer(Report) receipt (RM.RECEIVED) for all existing actors and demos; the receiver proposes it and the CASE_MANAGER creates it (CM-22-001), or the receiver creates it itself when it is the CASE_MANAGER (CM-12-001). |
Refines: CM-12-001 |
CM-15-002 |
MUST | When a receiver's auto_create_case is False, processing an inbound Offer(Report) MUST store the VulnerabilityReport and the Offer(Report) activity in the DataLayer.The report materials must be persisted even when case creation is suppressed so that they can be retrieved if the operator later enables case creation or reviews the offer manually. |
|
CM-15-003 |
MUST NOT | When a receiver's auto_create_case is False, processing an inbound Offer(Report) MUST NOT create a VulnerabilityCase.The purpose of the flag is to suppress automatic case creation; creating a case anyway would make the flag meaningless. |
|
CM-15-004 |
MUST | When a receiver's auto_create_case is False, processing an inbound Offer(Report) MUST leave the receiver's outbox unchanged (no outbound activities are enqueued).Suppressing case creation also suppresses the associated outbound activities (e.g., Create(Case) to the reporter); the observable outcome is no outbound messages. |
|
CM-15-005 |
MUST | The auto_create_case gate MUST be enforced as an in-tree condition node in create_receive_report_case_tree, after the intake node and ahead of any case-creating effect. A routing-level short-circuit in SubmitReportReceivedUseCase is not a conforming enforcement location, because it stores the report and the Offer(Report) before any tree runs (CLP-10-019). The observable outcome (no case, no outbound activities; report and Offer stored per CM-15-002) remains normative.Case creation is protocol-significant behavior (BT-15-001), so the gate lives in the behavior tree as a condition node. This requirement previously allowed a use-case short-circuit as an equivalent optimization; ADR-0111 retired that option because the short-circuit had to store the report and Offer itself, which is the store path CLP-10-019 forbids. The intake node now stores them for every inbound Offer(Report), so the gate has nothing left to do outside the tree (#3872). |
|
CM-15-006 |
SHOULD | The auto_create_case=True default in ActorConfig is a reference implementation choice, not a Vultron protocol requirement. An implementation that sets auto_create_case=False creates the case in a later, separate step (for example SvcCreateCaseUseCase with the report's report_id); once that case is linked to the report, the implementation SHOULD close it through the leave-case trigger (the Case Owner's Leave(VulnerabilityCase), TRIG-13-001) when the operator or user requests closure, as with an auto-created case.CONCERN-1902 observed that the report-closing trigger use case was never reached in the reference process because the case is always auto-created at RM.RECEIVED. ADR-0085 resolves this: the auto-create behaviour is an implementation choice made by the reference/demo process. However the case was created, once it holds the report its Case Owner closes it by Leave, and no trigger sends Close(Report) for it (CM-23-019, ADR-0145). |
Actor Suggestion Routing (Recommend-Actor Flow)
Invitation Flow — Enrichment and Roles
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-17-003 |
MUST | The Invite activity MUST embed the intended case_roles: list[CVDRole] for the invitee so that EmitInviteActorToCaseNode can include them in the wire activity.All invited participants getting caseRoles=[] is a protocol defect (issue #1288). The participant record created with the stub Invite (CM-11-006) MUST reflect the roles the Case Owner intended, whether those roles arrived via a direct trigger request or via the suggest-actor round-trip. |
|
CM-17-006 |
MUST | The CASE_MANAGER MUST commit exactly one CaseLedgerEntry for an emitted Invite activity, in the tree that emits it: the Invite is constructed, appended to the outbox, and then its entry is committed. The Invite MUST NOT be addressed to the CASE_MANAGER itself, and no received-side path MUST commit a second entry for it.The entry records an Invite already sent (SYNC-09-002, ADR-0119). The emitting tree's commit is the Invite's only one: the loopback copy an earlier design assumed committed the same Invite a second time (#2996, ADR-0109). A retry after a send whose commit failed rebuilds the Invite under its first id, so the outbox does not queue it twice while it is pending (OX-06-002), a delivered one reaches its receiver again under the same id and is dropped (ID-02-001), and the commit records it once. |
Implements: CLP-10-006 |
CM-17-007 |
MUST | In the direct-trigger path (the Case Owner calls invite-actor-to-case with a roles list), SvcInviteActorToCaseUseCase MUST send the Case Owner's own recommend-actor Offer(Actor, Case) carrying that roles list as its suggested roles to the CASE_MANAGER, from the Case Owner's identity and outbox. It MUST NOT execute the invite tree locally as the CASE_MANAGER. The CASE_MANAGER's recommend-actor received tree MUST, when the recommender holds CVDRole.CASE_OWNER for the case, emit the Invite directly with the offered roles instead of forwarding the Offer to the Case Owner (CM-16-003, CM-16-004), and the emit MUST carry those roles so that caseRoles is never empty (CM-17-003).The owner's direct invite is the recommend-actor flow with the recommender and the decider being the same participant, so it reuses that flow rather than adding a message type. This is one path for every topology: when the Case Owner's container also hosts the CASE_MANAGER the Offer loops back over HTTP (OX-12-001), and when it does not the Offer is the only way to reach the CASE_MANAGER without emitting in its name from a store that is not the ledger's home (ADR-0109). The former wording had the trigger write suggested_roles to the blackboard for a locally executed emit; the roles now travel in the Offer, and the CASE_MANAGER reads them from the received Offer it stored — the stored activity, not a value the trigger response carried, the same discipline CM-16-018 applies to the CASE_MANAGER's own outgoing Offer on Accept. |
|
CM-17-008 |
MUST | In the suggest-actor received path (the CASE_MANAGER receives Accept(Offer(CaseParticipant))), the invitee's CaseParticipant.case_roles MUST be populated when the CASE_MANAGER emits the stub Invite (CM-11-006), from the roles that Invite carries, originating from the Case Owner's Accept response (CM-16-006) or the CASE_MANAGER's default role assignment (CM-16-003).In this path AcceptOfferCaseParticipantReceivedUseCase does not write suggested_roles. The roles the Invite carries are the originally intended roles, and the participant record is born with the Invite. |
|
CM-17-011 |
MUST | When the stub Invite carries the active embargo (CM-17-002), this codebase MUST embed the full EmbargoEvent in the case stub, every term as the inviter holds it, not only its ID and end_time. When the inviter cannot embed the active embargo (it holds only the ID, or the embargo object it holds is not the case's active embargo), it MUST NOT send the stub Invite.A prototype decision, not a protocol one (maintainer ruling, 2026-10-15, #4673). A real implementation could let the invitee fetch the embargo from the inviter by its ID; this prototype does not build that fetch, so the stub carries the embargo whole, and a stub naming it by ID alone would leave the invitee no way to read the terms (CM-17-002). Embedding the object as held keeps the sender's own start time and name, which a copy rebuilt from the ID and end time did not. |
Refines: CM-17-002 — CM-17-002 lets the stub carry the embargo inline or by ID; this codebase always carries it inline |
CM-17-012 |
MUST | When the CASE_MANAGER receives Accept(Invite(Actor, VulnerabilityCaseStub)) from an invitee whose participant record is already joined, or removed, it MUST treat the Accept as a duplicate: it commits no ledger entry, changes no participant record or consent row, and sends no case snapshot, backfill or full-case Invite. A participant is joined when the case initialization sequence seated it or it has already accepted the stub Invite (CM-10-004). The test is the participant record alone, not any replication state the CASE_MANAGER holds for that participant.A joined participant's Accept of a stub Invite is a redundant signal (maintainer ruling, 2026-10-21, #5059). Case creation seeds the participants it seats as signatories (CM-14-003, CM-14-005), so the Accept changes nothing. A joined participant's consent to a later embargo comes only from its answers to that embargo's Invite. Replication state is no test: the embargo pause and ledger sync write it too. |
Refines: CM-17-004 Depends on: CLP-13-001 |
Participant Embargo Consent (per-embargo rows)
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-18-017 |
MUST | This codebase MUST raise when it reads a participant's consent row for an embargo register entry it holds no row for, rather than reading the missing row as any consent state (CM-18-001). How this codebase treats the defect CM-18-001 names; split from it so CM-18-001 states only the rows every participant holds (MS-12-010). Raising is how one implementation fails fast, and no other participant observes it. |
Implements: CM-18-001 |
CM-18-003 |
MUST | The consent row machine MUST implement exactly the following valid triggers, each applied to the row for one embargo: INVITE (from UNINVITED, DECLINED or TIMED_OUT, to INVITED), AGREE (from UNINVITED, INVITED, DECLINED or TIMED_OUT, to AGREED), DECLINE (from UNINVITED, INVITED, AGREED or TIMED_OUT, to DECLINED), TIME_OUT (from INVITED only, to TIMED_OUT) and CARRY_OVER (from every state except AGREED, to AGREED). No other trigger is valid from any state, and a row for an entry in a final register status (SUPERSEDED, REJECTED, CANCELLED, TERMINATED) accepts no trigger at all.DECLINED and TIMED_OUT are not terminal: a participant who declined or let an invitation time out can be re-invited (e.g., when embargo terms change). AGREED is not re-invitable, so a consent already given is not undone by asking again. AGREE is valid from DECLINED because an Accept after a decline is the participant changing its answer: it has just said it accepts, so its row moves directly, with no re-invite step (#4597). Only the participant's own answer moves a DECLINED row this way; seeding (CM-14-003, CM-14-005) and the Accept of the full-case Invite never override a decline. Per ADR-0048, AGREE and DECLINE are valid directly from UNINVITED, because consent is not always mediated by an invitation: a Finder who sets the embargo on their own case has no inviter; participants added during case initialization already have an embargo in scope (ADR-0041); and the reporter's consent is implicit in submitting the report (CM-14-005). Requiring a synthetic INVITED step for these paths would record an invitation event that never occurred. Per ADR-0093, DECLINE is also valid from AGREED: a participant bound by an embargo MAY explicitly withdraw consent by rejecting the active embargo (VP-13-007, VP-13-008). A rejection of a proposed embargo declines only that embargo's row and leaves the participant's row for the embargo in force alone (MSM-07-004). Per ADR-0118, TIME_OUT is the timer path only (CM-18-002); a late Accept the CASE_MANAGER honours moves TIMED_OUT → AGREED directly (EMB-17-002), and a late explicit Reject moves TIMED_OUT → DECLINED. CARRY_OVER is caused only by the activation of a revision that ends no later than the embargo it replaces (EP-05-001); it lifts a DECLINED row too, because agreeing to N days is agreeing to every shorter period and declining a shorter revision objects to losing time, not to keeping the embargo. A row whose embargo is final records the past, so nothing more can be consented to it; a late Accept of such terms is answered with an invitation to the current embargo (EMB-17-003). A lapse and a termination are not triggers: they are derived (CM-18-001, CM-18-016). |
Refines: CM-18-001 |
CM-18-005 |
MUST | A participant's consent row MUST be changed either by applying a PEC_Trigger through CaseParticipant.apply_pec_transition(embargo_id, trigger, entry_status=...), given the register status of the embargo's entry, and persisting the participant, or, when a ledger entry carries the row in full, by writing the row as carried through CaseParticipant.copy_consent_row(), called only by the one apply function after it checks the row's starting state (ADR-0124, CM-23-016). Code MUST NOT assign CaseParticipant.embargo_consents directly as the means of recording a consent change; the UNINVITED rows that record no answer are written by CaseParticipant.write_uninvited_rows().apply_pec_transition() validates the trigger against the consent table and the register status, and raises VultronInvalidStateTransitionError on an illegal transition or a frozen row, making consent writes fail-closed regardless of upstream BT guard coverage. Direct assignment to the rows bypasses the table, so a row could reach a state no trigger leads to. The legacy apply_pec_trigger() soft-fail helper was removed (CONCERN-1871); all consent-write sites MUST use CaseParticipant.apply_pec_transition() (or apply_pec_transition_if_legal() where an illegal trigger is a recorded no-op), except the copy write, which the apply function keeps fail-closed (CM-23-016). Per ADR-0048, ADR-0122, ADR-0124. |
Refines: CM-18-003 |
CM-18-006 |
MUST | A protocol-significant consent change MUST be reproducible on every replica from the canonical case ledger: it is applied by the same EmbargoLifecycle operation on the CASE_MANAGER after it commits the entry of the activity that caused it (the relayed Invite, the Accept or Reject, the expiry, the late-Accept honour), and by replay of that entry on each replica. The consent rows are not carried in any ParticipantStatus snapshot.Consent lives on the CaseParticipant rows, not on ParticipantStatus, so an Add(ParticipantStatus) entry carries no consent. The ledger entry of the causing activity is the record, and replaying it through the lifecycle operation gives every store the same rows. Per ADR-0048, ADR-0122. |
Depends on: CM-18-005 |
CM-18-007 |
MUST | The case-initialization sequence MUST establish the default embargo before seeding each participant's consent row for it, so that the participant record written at initialization carries its true post-initialization rows. Because AGREE is valid directly from UNINVITED (CM-18-003, ADR-0048), an initialization participant reaches its consent in a single transition, so a placeholder is unnecessary. Emitting two entries per participant would also shift every downstream log_index; PR #1746 was reverted for exactly that, having broken rvcv-extension VFD replication timing. Holding initialization to one entry per participant keeps log_index order stable and preserves the ADR-0079 property that log_index order is causal order (CLP-14-001). |
Depends on: CM-18-006 Depends on: CM-14-003 |
CM-18-009 |
MUST | Any consent trigger not listed in CM-18-003 MUST be rejected as a protocol error. Rejecting unlisted transitions fail-closes the consent table, preventing consent corruption from malformed or unexpected triggers. |
Refines: CM-18-003 |
CM-18-012 |
MUST | The case initialization sequence MUST commit exactly one add_participant_status_to_participant ledger entry per participant, and MUST NOT commit a placeholder entry for a participant followed by a corrective entry for the same participant.Emitting two entries per participant shifts every downstream log_index; PR #1746 was reverted for exactly that, having broken rvcv-extension VFD replication timing. Holding initialization to one entry per participant keeps log_index order stable. Because AGREE is valid directly from UNINVITED (CM-18-003, ADR-0048), an initialization participant reaches its consent in a single transition, so a placeholder is unnecessary. |
Depends on: CM-18-007 |
CM-18-016 |
MUST NOT | The embargo lifecycle MUST NOT write a consent row to record a lapse or an exit. Beyond the UNINVITED row a proposal writes for every participant (CM-18-001), a proposal, the activation of a longer revision and the termination of the embargo change no row of a participant that has not itself been invited, has not itself answered, is not the case owner activating the embargo, or whose agreement is not being carried over by containment (EP-05-001); a participant's lapsed or exited position is read from its rows and the register (CM-18-001).The reconciliation rules that lapsed signatories at activation, advanced holders of the activated id and exited every record at termination existed only to keep a scalar state in step with the per-embargo list (Concern #3884). With one row per embargo they have nothing to keep in step, and a write that re-introduced one would re-create the second record of consent that ADR-0122 removed. |
Refines: CM-18-001 |
Participant Index Discipline
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-19-001 |
MUST | case_participants (the persisted set of CaseParticipant records) is the authoritative source of participant membership. actor_participant_index (the in-memory dict mapping actor IDs to participants) is a cache derived from it. Divergence between these two structures is a hard error, not a recoverable state.A divergence means the cache has entries the authority doesn't know about (or vice versa), producing silently incorrect routing and access-control decisions. |
|
CM-19-002 |
MUST | Any code path that modifies case_participants (add, remove, update) MUST also update actor_participant_index atomically within the same transaction or method call. The index MUST NOT be allowed to lag the authority. |
Refines: CM-19-001 |
CM-19-003 |
MUST | Code that needs a participant by actor ID MUST read from actor_participant_index (the cache). Code that needs the canonical participant set (e.g., for broadcast or audit) MUST read from case_participants. Using the cache for canonical iteration is prohibited because cache population order is undefined. |
Refines: CM-19-001 |
Ownership Transfer Protocol Effects
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-21-004 |
MUST | The strip of CVDRole.CASE_OWNER from the previous holder (CM-21-003), the update of VulnerabilityCase.attributed_to, and the grant of CVDRole.CASE_OWNER to the new holder (CM-21-002) MUST be committed to storage as a single atomic transaction. If any part of the write fails, the entire set MUST be rolled back so that no partial state is observable.Without atomicity a crash or exception between the strip and the grant produces a case with zero CASE_OWNER holders — a state worse than two simultaneous holders because it cannot be recovered by any subsequent normal protocol message. The invariant in CM-21-001 ("at most one at any point in time") logically implies that the case MUST have exactly one CASE_OWNER at all times; this spec makes the storage-transaction requirement explicit so that DataLayer implementations cannot satisfy CM-21-001 through sequential saves. |
Refines: CM-21-001 Refines: CM-21-002 Refines: CM-21-003 |
RM Case Closure via Leave(VulnerabilityCase)
Requirements for the canonical RM case closure path: Leave(VulnerabilityCase) is the sole mechanism for advancing a participant's RM state to CLOSED. Add(ParticipantStatus, rmState=CLOSED) is not a valid closure trigger. See ADR-0050 (Leave is canonical for RM closure) and ADR-0051 (CASE_MANAGER RM lifecycle). Decided: the maintainer decided the closure semantics in CM-23-002, CM-23-013 and CM-23-014 (what the owner's close does to pending Invites and to later Leaves) on 2026-10-12. ADR-0085 sets the owner-close write boundary. ADR-0131 sets the CM-23-013 reply to late traffic: by default it is dropped, and only a late Leave may get an as:Reject (#4065). The CM-23-014 settle step is #4066 (PR #4489).
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-23-017 |
MUST | The reference CASE_MANAGER MUST choose its CM-23-013 reply at one configuration point, ActorConfig.post_close_reply, whose default is drop. With drop it sends nothing for any activity that arrives after the case is fully closed (CM-23-013). With reject it answers a late Leave(VulnerabilityCase) with Reject(Leave(VulnerabilityCase)) and sends nothing for any other late activity. Every other late activity MUST be dropped with no reply under both settings, and a late activity MUST be reported REFUSED under both. The drop MUST be logged at INFO with the activity id, the sender's actor id and the case id.A Reject that wraps most activities already carries a protocol meaning for its recipient: Reject(Invite) is a declined invitation and Reject(Offer(...)) a declined offer, so a reply to a late Invite or Offer would be misread as a decision. Reject(Leave(VulnerabilityCase)) already means "close declined" (CM-23-011) and cannot be misread. Dropping is the default because it is what an exited process does, and CM-23-013 already tells participants not to rely on a reply. |
Refines: CM-23-013 |
CM-23-020 |
MUST | In this codebase the ACTIVE_PARTICIPANT sender standing (HP-01-009) MUST mean an active participant (CM-10-004) whose closure the case's ledger does not record, and every guard that enforces it MUST ask one shared predicate (HP-01-007). The Reject(CaseLedgerEntry) use case MUST NOT declare that standing: its guard stays the active check of SYNC-03-005, so a closed sender's resync request reaches the closure-entry replay CM-23-018 requires.How this codebase's sender guards meet CM-23-018; split from it so CM-23-018 states only what a closed sender is refused and sent (MS-12-010). Sender standings and guard composition are this codebase's mechanism (HP-01-007). |
Implements: CM-23-018 Refines: HP-01-007 |
CASE_MANAGER-Delegated Activity Authorship Contract
Requirements for how trigger use cases assign actor and attributed_to when the CASE_MANAGER emits an Activity on behalf of a requesting participant (delegated message). The reference implementation is delegated_authorship() (vultron/core/behaviors/delegated_authorship.py). Codified from CONCERN-2170.
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-24-005 |
MUST | Every path that emits an Activity on another participant's behalf — a trigger use case, or a received-side tree performing the CM-24-004 delegated emit — MUST obtain the Activity's actor and attributed_to from one shared helper. The helper MUST set actor to the actor doing what was asked (the CASE_MANAGER executing the gated tree) and attributed_to to the actor that asked (the trigger's requesting actor, or the sender of the activity the received tree acts on). It MUST live in a module that both use cases and BT nodes may import (BTND-04-003). No callsite may reconstruct the delegated-authorship pattern without it.Rebuilding the actor/attributed_to assignment at each callsite drifted: CONCERN-2170 found two wrong callsites. Neither value needs computing, since the doer and the asker are known where the emit happens, and the received-side relay is a delegated emit too (CM-24-004). The helper first sat in the trigger layer, which BT nodes may not import, so the relay rebuilt the pattern by hand; #4160 moved it to vultron/core/behaviors/delegated_authorship.py. |
Refines: CM-24-004 Depends on: BTND-04-003 |
CM-24-008 |
MUST | A trigger, received tree or shared helper in this codebase MUST NOT carry an arm for a case with no CASE_MANAGER (CM-24-006): a resolver that finds none MUST fail, and MUST NOT succeed with a warning. How this codebase keeps CM-24-006 from being bypassed; split from it so CM-24-006 states only that a case always has a CASE_MANAGER and that no activity goes around it (MS-12-010). Trees, helpers and resolvers are this codebase's structure. |
Implements: CM-24-006 |
Embargo Invite RSVP Deadlines
Requirements for the optional RSVP-by deadline carried on an embargo invitation, the authority relationship between that wire value and the CM-18-002 pocket-veto policy window, and how an expiry is recorded. Codified by ADR-0065. Source: IDEA-2066.
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-28-006 |
MUST | An inbound Invite.end_time MUST be normalised to UTC before any comparison against the current time, per CS-13-001. A naive (non timezone-aware) Invite.end_time MUST be normalised to UTC at the wire edge per ADR-0032, so that downstream comparisons are always against timezone-aware values.A deadline compared in the wrong timezone lapses at the wrong instant, breaking the bilateral determinism CM-28-002 exists to provide. ADR-0032's edge normalisation (naive → UTC) prevents this without per-field guarding in the extractor. See CS-13-001 through CS-13-005 and ADR-0065. |
Refines: CS-13-001 |
CM-28-007 |
MUST | Embargo invite expiry MUST be evaluated lazily: expiry is derived from (end_time, now) whenever a participant's consent rows are read or an inbound Accept/Reject for the invitation is processed.Lazy derivation from an explicit deadline is deterministic and requires no new infrastructure. No scheduler exists in production, and the Sentinel invocation model remains an open question (notes/coordination-agents.md). See ADR-0065. |
Refines: CM-28-003 |
CM-28-008 |
MUST NOT | Implementations MUST NOT require a scheduler or Sentinel runtime for embargo invite expiry correctness. No scheduler exists in production; making correctness depend on one would render conformance impossible without new infrastructure. The Sentinel is an optional accelerator only. See ADR-0065. |
Refines: CM-28-003 |
CM-28-010 |
MUST NOT | The expiry-versus-refusal provenance MUST NOT be duplicated onto a consent row as a reason field. A reason discriminator on a consent row — which holds only the embargo id and the state — would be a second source of truth able to drift from the ledger. The canonical ledger is the sole record of provenance (ADR-0019). See ADR-0065. |
Refines: CM-28-005 |
CM-28-016 |
MUST | In this codebase the CASE_MANAGER's commit of the invite-expiry Update(EmbargoConsent) (CM-28-014) MUST sit behind the CASE_MANAGER role gate (BT-17-001), and a replica MUST apply the entry through the consent slot of create_announce_log_entry_tree (RSH-08-004).How this codebase keeps the expiry entry the CASE_MANAGER's alone and copies it onto replicas; split from CM-28-014 so CM-28-014 states only who records an expiry and what replicas learn from it (MS-12-010). |
Implements: CM-28-014 Depends on: BT-17-001 Depends on: RSH-08-004 |
CM-28-015 |
MUST | The CASE_MANAGER MUST expire every INVITED consent row whose RSVP deadline has passed and whose embargo register entry is an open proposal or the embargo in force, each through its CM-28-009 Update(EmbargoConsent) and TIME_OUT, before it commits the case owner's decision on an embargo proposal (MSM-07-008, MSM-07-009) and whenever it processes any other received embargo activity for the case. A row of a replaced, rejected, cancelled or ended embargo is frozen and MUST NOT be swept. The sweep is a clock decision (CLP-10-025, ADR-0135): it MUST run only after the door guards admit the activity, its sender gate (HP-01-006) included, so a sender with no standing makes the CASE_MANAGER write nothing, and it runs before the activity's own refusals, such as an embargo that is not an open proposal or a public, exploited or attacked CS (MSM-07-008), so its expiries are recorded even when the activity is then refused. It applies equally when the owner's own trigger runs on the actor that holds CVDRole.CASE_MANAGER (EP-09-008), and to every other embargo trigger that actor runs.Expiry checked only on a late Accept and at owner close (CM-23-014) never records an invitee that does not answer, so the case owner decides without knowing of the timeout (EP-09-006, MSM-07-007). Embargo activities read the consent rows (CM-28-007), so sweeping there keeps expiry lazy (CM-28-008). An overdue row is a fact from the CASE_MANAGER's clock, not evidence from the activity, so it is recorded whatever the activity's fate (ADR-0135, ISSUE-4640). |
Refines: CM-28-007 Depends on: CM-28-009 Depends on: CM-28-014 Depends on: CLP-10-025 |
Case Collection Mutation Discipline
Requirements governing post-construction mutation of the case collections whose items exist in incompatible wire and core shapes. validate_assignment does not cover in-place mutation — list.append is not an attribute assignment, so Pydantic never observes it — so these collections need a separate prohibition. Codified by ADR-0064. Source: CONCERN-2261.
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-27-001 |
MUST NOT | Core code under vultron/core/ MUST NOT mutate case_participants or case_statuses directly on a VulnerabilityCase instance after construction (e.g. case.case_participants.append(...), case.case_statuses = [...]). The canonical mutators on VulnerabilityCase MUST be used for all post-construction changes. The mutator methods themselves, in vultron/core/models/case.py, are exempt.A wire-shaped participant or status appended to a core-typed list does not raise when read — it reads as absent, and readers substitute an initial state, silently resetting the case's ladder (#2232, #2264). validate_assignment cannot close this door because append is not an assignment. This mirrors PRM-03-001, whose identical prohibition drove direct case_roles mutation in vultron/ to zero sites. |
Refines: ARCH-21-001 |
CM-27-002 |
MUST | Passing case_participants=[...] or case_statuses=[...] as a constructor argument when creating a VulnerabilityCase is permitted and does not violate CM-27-001; the prohibition applies only to post-construction mutation.Constructor arguments are validated, which is precisely the guarantee CM-27-001 exists to restore. This clarification prevents over-application of the rule, mirroring PRM-03-002. |
Refines: CM-27-001 |
CM-27-003 |
MUST | VulnerabilityCase MUST expose a canonical mutator for appending a case status, so that CM-27-001 can be satisfied without direct list mutation. add_participant() already provides this for case_participants; removal keeps the record on the roster (CM-31-001), so the roster has no deleting mutator.A prohibition with no sanctioned alternative is unimplementable. At the time of writing case_statuses had no mutator at all — only the current_status and case_status readers — so the rule requires one to be added. |
Refines: CM-27-001 |
Case Status Recency Resolution
Rules for determining which CaseStatus snapshot is the most recent when multiple snapshots exist in the case_statuses history.
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-29-001 |
MUST | VulnerabilityCase.current_status MUST determine the most-recent CaseStatus by comparing updated then published timestamps only. The id_ field MUST NOT be used as a sort tiebreaker.Using id_ as a fallback when timestamps are absent produces scheme-dependent ordering: auto-seeded statuses carry UUID-based IDs (urn:uuid:…, starting with 'u') which sort lexically higher than HTTPS-scheme IDs (https://…, starting with 'h'), causing the initial auto-seeded status to beat a legitimately newer received status when both lack explicit timestamps. ID scheme is an implementation artefact, not a time proxy. When timestamps are absent or equal, the fallback MUST resolve to the earliest possible time (e.g., datetime.min) so that timestampless statuses sort to the bottom rather than the top. Source: #2737. |
Refines: CM-27-001 |
Participant Removal and Reinstatement
Removing a participant withdraws its entitlement to case content and keeps its record. The Case Owner requests removal and reinstatement; the CASE_MANAGER applies them, records each as one ledger entry, and tells the affected participant directly (ADR-0116).
| ID | Priority | Requirement | Related |
|---|---|---|---|
CM-31-014 |
MUST | This codebase MUST NOT store a case's activeParticipants collection (CM-31-003): it is computed from the participant records whenever the case is serialized.How this codebase keeps CM-31-003's collection from drifting from the participant records; split from it so CM-31-003 states only what the case carries on the wire and what a receiver does with it (MS-12-010). |
Implements: CM-31-003 |
CaseProposal Protocol
Requirements for the CaseProposal protocol flow, which separates the act of requesting case initialization from the act of creating the case. Covers the wire vocabulary type, the Create/Accept/Reject message flow, and the corresponding MessageSemantics values and received-side use cases.
File ID: CP
Wire Vocabulary
| ID | Priority | Requirement | Related |
|---|---|---|---|
CP-01-001 |
MUST | The wire layer MUST define an as_CaseProposal object type that extends as_VultronObject.A dedicated object type enables pattern-discriminated routing in the semantic extractor and preserves AS2 semantics by separating the proposal object from the eventual VulnerabilityCase. |
|
CP-01-002 |
MUST | as_CaseProposal MUST include an id_ field containing a full URI that uniquely identifies the proposal. |
|
CP-01-003 |
MUST | as_CaseProposal MUST include an attributed_to field identifying the report receiver that originated the proposal (the proposer, who becomes the CASE_OWNER if the proposal is accepted). |
|
CP-01-004 |
MUST | as_CaseProposal MUST include an object_ field containing a fully inline as_VulnerabilityReport around which the case is to be created. URI references to the report are NOT permitted (see AKM-03-003).The case-actor service needs the vulnerability report to initialize the case. URI references are prohibited because the receiver may not have the referenced object and this codebase does not yet dereference (AKM-05-003, AKM-03-003). |
|
CP-01-005 |
MUST | as_CaseProposal MUST include a target field containing the URI of the prospective case-actor service to which the proposal is addressed. |
|
CP-01-007 |
MUST | When the proposing actor knows which Offer(VulnerabilityReport) brought it the report, as_CaseProposal MUST carry that provenance as offerId and offerActorId, and the case-actor MUST use it for the offerId of the canonical add_report_to_case ledger entry whenever its own store holds no OfferRecord for the report.The canonical add_report_to_case entry records which Offer brought the report into the case, so every replica and every auditor of the ledger can trace the report to its submission (SYNC-02-002). The case-actor cannot supply that provenance from storage: the OfferRecord belongs to the actor that received the Offer, and a co-located case-actor has its own store with no read into a sibling's (PCR-01-003, DL-07-005). So it travels on the proposal for the same reason and by the same rule as the report itself (CP-01-004). A participant that joined through an Invite does not use it to answer the Offer, which was never sent to it (CM-11-018). |
Refines: CP-01-004 Implements: PCR-01-003 |
CP-01-008 |
MUST | When the proposing actor holds the Offer(VulnerabilityReport) that brought it the report, as_CaseProposal MUST carry that Offer whole, inline, as inReplyTo. When both inReplyTo and the bare provenance of CP-01-007 are present they MUST name the same Offer. The case-actor MUST read the Reporter's proposed embargo terms (EP-04-004) from the Offer so carried, and MUST NOT fetch or reconstruct them from anywhere else.The case-actor has never seen the Offer, and a sender inlines the objects it introduces (ADR-0107); the bare offerId of CP-01-007 lets the case-actor name the submission but not read it. Carrying the Offer whole is what lets the Reporter's terms arrive as the Reporter stated them — the same activity the report receiver received, not the receiver's paraphrase of one field — and it is the only path by which EP-04-003's shortest-wins comparison at case creation ever gets a sender candidate. inReplyTo is AS2's own name for the relationship: the proposal answers the report submission. |
Refines: CP-01-007 Implements: EP-04-004 |
CP-01-010 |
MUST | In this implementation, the actor of Create(as_CaseProposal) MUST be the proposing actor's full actor profile inline, carrying its embargo policy when it has published one, and its id MUST equal the proposal's attributed_to. The CASE_MANAGER MUST read the CASE_OWNER's actor default only from that inline profile, MUST NOT fetch it, and MUST NOT retain it as a candidate for any other case. A proposal whose actor is a bare URI, or whose inline profile names a different actor than attributed_to, MUST be refused at the parse edge with a reason naming the fault. A profile with no embargo policy means the CASE_OWNER has no actor default.Carrying the profile follows the rule CP-01-008 applies to the Reporter's terms: a sender inlines the objects it introduces (ADR-0107), so case creation never waits on, or fails because of, a fetch. Refusing a bare URI rather than accepting it without an actor default keeps a malformed proposal from silently dropping the owner's published terms. A policy read from one proposal reflects that profile as sent; keeping it for later cases would let a stale policy win shortest-wins. |
Refines: CP-01-009 |
MessageSemantics Values
Extractor Patterns
| ID | Priority | Requirement | Related |
|---|---|---|---|
CP-03-001 |
MUST | SEMANTIC_REGISTRY MUST contain a pattern entry for (Create, as_CaseProposal) -> CREATE_CASE_PROPOSAL. |
|
CP-03-002 |
MUST | SEMANTIC_REGISTRY MUST contain a pattern entry for (Accept, as_CaseProposal) -> ACCEPT_CASE_PROPOSAL. |
|
CP-03-003 |
MUST | SEMANTIC_REGISTRY MUST contain a pattern entry for (Reject, as_CaseProposal) -> REJECT_CASE_PROPOSAL. |
|
CP-03-004 |
MUST | All three CaseProposal extractor patterns MUST appear before any more-general patterns that could match the same activity types (e.g., generic Accept or Reject catch-all entries), to prevent misrouting (per SE-03-002). |
Depends on: SE-03-002 |
Protocol Flow — Sending
| ID | Priority | Requirement | Related |
|---|---|---|---|
CP-04-002 |
MUST | The BT node responsible for sending Create(as_CaseProposal) MUST be a distinct node (ProposeReportCaseToActorNode) separate from EnsureCaseActorHostedNode. EnsureCaseActorHostedNode is responsible only for making the case-actor a hosted actor; it MUST NOT send a CaseProposal activity.Conflating actor registration with proposal sending violates single responsibility and causes the proposal to be sent before the case-actor service is ready. See notes/case-proposal.md for the pitfall. |
|
CP-04-004 |
MUST | Whatever provisions the case-actor's record MUST write it into the case-actor's own store, since the inbox route resolves an actor from the store its URI names. A copy in the sending actor's store does not make the case-actor addressable. Under per-actor storage isolation (ADR-0073) the two writes mean different things: one publishes an endpoint, the other records knowledge of a peer. Writing only the second left the inbox answering 404 Actor not found (ISSUE-1872). Under the pre-ADR-0073 shared store they were indistinguishable, which is why one write used to suffice. |
Refines: CP-04-003 Refines: DL-07-001 |
Protocol Flow — Receiving (Case-Actor Side)
| ID | Priority | Requirement | Related |
|---|---|---|---|
CP-05-009 |
MUST | This codebase MUST carry the as_CaseProposal inline as the object_ of the Accept(as_CaseProposal) of CP-05-003 and of the Reject(as_CaseProposal) of CP-05-004, and the inline object MUST survive the outbox store round-trip rather than dehydrating to a bare URI.How this codebase meets CP-05-003 and CP-05-004; split from them so they state only what other participants observe (MS-12-010). Carrying the proposal inline rather than by reference is a shortcut of this prototype reference implementation, which does not yet dereference (AKM-05-003), not a protocol rule. |
Implements: CP-05-003 Implements: CP-05-004 |
Testing
Case-Actor Service URL Configuration
| ID | Priority | Requirement | Related |
|---|---|---|---|
CP-08-001 |
MUST | Every actor that may trigger the engage-case BT path (i.e., any actor that creates a VulnerabilityCase on behalf of another actor) MUST supply a case_actor_service_url value in its ActorConfig.ResolveCaseActorUrlsNode previously derived the CaseActor service URL by prepending server_base_url (the running container's own base URL) to a case slug. This hard-wires a co-location assumption: the CaseActor is assumed to live on the same host as the BT executor. In a multi-container topology (DEMOMA-01-001), the CaseActor has its own container and URL; using server_base_url routes proposals to the wrong endpoint. Actor URLs are opaque endpoint identifiers; whether they are host-local or host-remote is irrelevant to callers. The only correct source for the CaseActor service URL is explicit configuration. This is a TEMPORARY per-actor config field pending a proper actor-ID-to-endpoint resolution mechanism (see issues #1189, #1092). |
Implements: CP-01-005 |
CP-08-004 |
MUST | The case_actor_service_url field in ActorConfig MUST be a valid HttpUrl.A non-URL value cannot be used to construct a valid inbox endpoint address, so validation at configuration load time is required to surface errors early. |
Refines: CP-08-001 |
CP-08-005 |
MUST | Absence of case_actor_service_url in ActorConfig MUST produce a runtime FAILURE at ResolveCaseActorUrlsNode with a clear error message identifying the missing configuration, not a silently mis-routed proposal.Silent mis-routing sends the proposal to the wrong endpoint without any observable error signal, making the failure hard to diagnose. |
Refines: CP-08-001 |
CP-08-002 |
MUST | ResolveCaseActorUrlsNode MUST read case_actor_service_url from ActorConfig (via get_config().actor.case_actor_service_url) to derive the CaseActor service URL. |
Refines: CP-08-001 |
CP-08-006 |
MUST NOT | ResolveCaseActorUrlsNode MUST NOT use server_base_url from the blackboard or from the server configuration to construct case_actor_id.Using server_base_url hard-wires a co-location assumption; in a multi-container topology the CaseActor has its own container and URL. |
Refines: CP-08-002 |
CP-08-007 |
MUST NOT | The server_base_url blackboard key MUST NOT be registered or read in ResolveCaseActorUrlsNode.setup().Reading server_base_url in setup() would reintroduce the co-location coupling that CP-08-002 and CP-08-006 prohibit. |
Refines: CP-08-002 |
CP-08-003 |
MUST | In all multi-container demo scenarios, every actor whose BT may run the engage-case path MUST have VULTRON_ACTOR__CASE_ACTOR_SERVICE_URL set in its Docker Compose environment block (or equivalent config mechanism).Demo containers are "omniscient" about all actor URLs in the deployment at boot time (in lieu of a directory service, issues #1189, #1092). The env-var approach mirrors the VULTRON_SERVER__BASE_URL pattern and requires no new discovery mechanism. |
Refines: CP-08-001 |
CP-08-008 |
MUST | The VULTRON_ACTOR__CASE_ACTOR_SERVICE_URL value MUST be the full base URL of the dedicated CaseActor container (e.g., http://case-actor:7999/api/v2).A partial URL or path-only value cannot be used to construct a valid inbox endpoint; the full base URL is required to route proposals correctly. |
Refines: CP-08-003 |
CP-08-009 |
MUST NOT | Demo scripts MUST NOT rely on server_base_url derivation for the CaseActor identity.server_base_url is the running container's own base URL and does not identify a separate CaseActor container; relying on it silently routes proposals to the wrong endpoint. |
Refines: CP-08-003 |
CASE_MANAGER-Authoritative Initialization on Accept(CaseProposal)
Requirements for the CASE_MANAGER to perform all case initialization natively when accepting a CaseProposal, per ADR-0041. These replace the receiver-side case-creation and prologue back-fill that ADR-0015 introduced.
| ID | Priority | Requirement | Related |
|---|---|---|---|
CP-09-010 |
MUST | This codebase MUST embed the participant objects of the CASE_MANAGER's Create(VulnerabilityCase) inline, not as bare ID strings, so that the report receiver's CreateCaseReceivedUseCase can seed its local participant records without a DataLayer round-trip to the CASE_MANAGER.How this codebase meets CP-09-004 (split per MS-12-010). Embedding the participants inline is a shortcut of this prototype, which does not yet dereference (AKM-05-003), not a protocol rule. store_embedded_participants parses embedded participant objects. Bare IDs produce empty participant records on the receiver's replica, causing role-guard failures for operations that require CASE_OWNER or CASE_MANAGER checks. |
Implements: CP-09-004 |
CP-09-005 |
MUST NOT | The report receiver MUST NOT create a VulnerabilityCase object in its own DataLayer before receiving Create(VulnerabilityCase) from the CASE_MANAGER. The receiver's receive_report_case_tree MUST store the inbound report and write a pending VultronReportCaseLink, then send Create(as_CaseProposal) and wait.Creating a receiver-local VulnerabilityCase before the CASE_MANAGER responds produces a second, non-authoritative case object and forces a back-fill workaround to reconcile the two copies. This was the root cause of WritePrologueLedgerEntriesNode (Issue #1688) and of Issue #1767 (ADR-0041). |
Refines: CP-09-001 |
Code Style Specifications
Defines code formatting, import organization, and coding style standards for Python code in the Vultron project.
File ID: CS
Code Formatting
Import Organization
Import Practices
Import Style Preferences
Circular Import Prevention
| ID | Priority | Requirement | Related |
|---|---|---|---|
CS-05-002 |
SHOULD | A circular dependency SHOULD be resolved by reorganization first (a neutral module per CS-05-003, or moving the shared symbol to the side of the cycle that owns it). Deferring the import into a function body is a last resort that hides the cycle rather than removing it, and every such deferral is tracked toward structural removal (CS-05-005). A measurement while planning #2199 (recorded in ADR-0094) found function-local imports throughout the tree. A follow-up measurement for #3350 (hoisting every one in vultron/ and importing every module) showed that only a handful actually break a cycle; the rest were habit. "Last resort" therefore describes the design correctly, and the gap was enforcement, not the requirement. The resolution is tracked from #3350. |
|
CS-05-005 |
MUST | In production code under vultron/, a function-local import MUST exist only to break an import cycle that reorganization has not yet removed, and MUST carry a # noqa: PLC0415 marker whose trailing comment names the issue that tracks its structural removal (the IMPLTS-07-020 baseline form). A function-local import with no cycle to break MUST be hoisted to module level. A TYPE_CHECKING-guarded import is not a function-local import and needs no marker.Enabling the rule makes the exception the visible, reviewable artifact. Because RUF100 (unused-noqa) stays selected, a marker whose cycle has been removed fails lint, so the backlog can only shrink and the last marker cannot be left as decoration. Growth fails, silent progress fails, and completion forces the marker's deletion, which is the ratchet property set ADR-0064 requires (ADR-0094 chose RUF100 as that forcing function). |
Refines: CS-05-002 Depends on: IMPLTS-07-020 |
CS-05-006 |
MAY | Test modules under test/ MAY import inside test functions and fixtures. This idiom is exempted from PLC0415 through a per-file-ignores entry for test/** rather than through per-site markers.A test-local import scopes a dependency to the one test that needs it and keeps a module-under-test out of collection-time import order ( isolated_core_registries, monkeypatch of module attributes). It is an isolation idiom, not a cycle break, so CS-05-002 does not govern it and CS-05-005's marker discipline would add noise without a backlog to drain. |
Refines: CS-05-005 |
CS-05-004 |
MUST | Before adding imports between modules, trace the import chain to prevent cycles |
Module Size
| ID | Priority | Requirement | Related |
|---|---|---|---|
CS-06-001 |
SHOULD | Prefer modules that are < ~400 lines; split large modules by responsibility and avoid single-file catchalls |
as_ Field Prefix Policy
Optional Field Non-Emptiness
| ID | Priority | Requirement | Related |
|---|---|---|---|
CS-08-001 |
MUST NOT | Optional string fields MUST NOT contain empty strings when present; the invariant is "if present, then non-empty" Distinguishes "not provided" from "provided but blank", avoiding ambiguous database states. |
|
CS-08-002 |
SHOULD | Non-empty string validation SHOULD be consolidated into a shared type alias rather than duplicated per-field validators Reduces boilerplate and makes the invariant visible in the type signature rather than buried in a per-field validator. |
Refines: CS-08-001 |
Code Reuse
Port and Adapter Data Exchange
| ID | Priority | Requirement | Related |
|---|---|---|---|
CS-10-003 |
MUST | In this Python codebase, port boundary types MUST be named Pydantic model classes (e.g., VulnerabilityCase, CaseParticipant), not plain dicts or bare BaseModel subclasses without semantic meaning. Port Protocol signatures MUST declare parameters and return values as these specific domain types.Prior to domain-typed core objects, "use Pydantic BaseModel" was the correct guidance (vs. plain dicts). With named domain types in place the standard is higher: bare BaseModel at a port is equivalent to dict from a contract-clarity perspective. See also ARCH-08-001 for the port Protocol obligation. |
Refines: CS-10-001 — Python/Pydantic-specific application of the typed-port-boundary pattern Refines: ARCH-08-001 |
Type Annotations
Domain Model Naming
Datetime and Timezone Conventions
Wire Model Configuration
| ID | Priority | Requirement | Related |
|---|---|---|---|
CS-14-001 |
MUST | Wire-layer Pydantic models (classes in vultron/wire/as2/vocab/) MUST inherit model_config from as_Base, which sets alias_generator=to_camel, validate_by_name=True, and validate_by_alias=True |
|
CS-14-002 |
MUST | Core/domain-layer Pydantic models (classes in vultron/core/models/) MUST set populate_by_name=True in their model_config |
|
CS-14-003 |
MUST | Domain event subclasses MUST narrow the semantic_type field to a Literal[MessageSemantics.FOO] type with the corresponding default value |
Refines: CS-10-002 |
No Compatibility Shims
Method and Variable Naming Safety
Pydantic Models for Structured Data
Module Size and Organization
| ID | Priority | Requirement | Related |
|---|---|---|---|
CS-18-001 |
SHOULD | A Python source file SHOULD NOT exceed 500 lines when it mixes multiple distinct responsibilities. When the 500-line threshold is crossed, the module MUST be split into a subpackage whose submodules are grouped by semantic concern. Large, multi-responsibility files accumulate technical debt, slow review cycles, and make targeted test coverage difficult. High-churn large files are especially prone to merge conflicts. The 500-line threshold is a practical signal, not a hard language constraint — a coherent single-responsibility module MAY exceed it if all concerns are tightly related. The key signal is mixed responsibility. |
Refines: BTND-07-001 |
CS-18-002 |
MUST | When a module is split into a subpackage, the resulting submodules MUST themselves comply with CS-18-001. The split pattern applies recursively: a submodule that exceeds 500 lines and mixes responsibilities MUST be further decomposed. Splitting a god module into a package does not eliminate the problem if individual submodules re-accumulate size. A recursive constraint closes this loophole. |
Refines: CS-18-001 |
CS-18-003 |
MUST | A subpackage created by splitting a flat module MUST re-export all previously public names from its __init__.py to preserve import compatibility with callers.Callers that previously imported from vultron.foo.nodes import SomeNode MUST continue to work without modification. Exposing all names via __init__.py prevents a split from becoming a breaking change. |
Refines: CS-15-001 |
CS-18-004 |
SHOULD | The test suite for a subpackage created by splitting a flat module SHOULD mirror the subpackage structure: a test/…/nodes/ (or equivalent) directory with one test_<submodule>.py per submodule.A monolithic test file grows in parallel with the flat module and inherits the same blast-radius problem. Mirrored test files scope failures to the affected concern and make it easier to add focused per-unit tests. |
Refines: BTND-07-002 |
Protocol and Type-Guard Hygiene
| ID | Priority | Requirement | Related |
|---|---|---|---|
CS-20-001 |
MUST | When a field or method is removed from a concrete class that structurally conforms to a Protocol, the matching declaration MUST also be removed from the Protocol (and vice versa). Protocol members that no longer exist on any conforming concrete class MUST NOT be left as stale declarations.Static type checkers (mypy, pyright) verify the concrete→Protocol direction only: they confirm that the concrete class satisfies the Protocol, but they do NOT flag Protocol members that have been removed from every concrete implementation. A stale Protocol member silently breaks future callers that accept a Protocol-typed parameter and access the removed attribute — typically surfacing only at runtime. Issue #792 exposed this gap when CaseModel.events was left in protocols.py after VulnerabilityCase.events was removed. |
Refines: CS-11-001 — Explicit typing must stay accurate; stale Protocol members are a form of inaccurate type annotation |
CS-20-002 |
MUST | TypeGuard discriminator functions (e.g., is_case_model()) MUST use only fields or methods that are explicitly declared on the target Protocol as their hasattr/isinstance checks. Using a method or field that is NOT in the Protocol as a discriminator is forbidden.If a discriminator tests a method that later gets removed from the concrete class (and not from the Protocol), the guard silently starts returning False for valid objects. Issue #888 caused is_case_model() to return False for all stored cases when record_event() — which was never a Protocol member — was removed from VulnerabilityCase. 440 tests failed downstream before the root cause was traced. Using only Protocol-declared members as discriminators ensures the guard stays correct as long as the Protocol itself is maintained (per CS-20-001). |
Refines: CS-20-001 |
Test Fixtures Mirroring Production Enums
Collection-Typed Pydantic Field Defaults
Extract Before Reuse
Exception Handler Breadth
| ID | Priority | Requirement | Related |
|---|---|---|---|
CS-23-001 |
MUST NOT | A blanket except Exception (or a bare except:) MUST NOT be used to wrap domain logic in vultron/. An exception handler MUST catch the narrowest concrete exception type(s) it can meaningfully handle, so that an unexpected error surfaces loudly instead of being silently absorbed. The one sanctioned broad-catch boundary is a behavior-tree node's update() method, where converting an unexpected error into Status.FAILURE is the documented node contract (BT-HELPER-01). Any other broad catch is permitted only at a genuine framework or execution boundary (e.g., the BTBridge execution boundary, py_trees setup()/initialise()); such a site MUST be narrowed to the narrowest type that covers the boundary and MUST carry an inline comment stating what it guards and why.In this codebase an undocumented broad catch is more likely to be masking a defect than handling a real case; the absence of a rationale is itself the signal. Two witnesses established the pattern — #3217 (a parser except Exception that fired 52 times with one wire/core layering cause, not the stubs it appeared to tolerate) and #3192 (a lookup returning an arbitrary Service, reporting SUCCESS under a different identity). Both were single bug-shaped causes. Source: CONCERN-3295. |
Refines: ARCH-15-002 — ARCH-15 forbids silent-None fail-open; CS-23 forbids the broad-catch form of the same masking failure |
Configuration Management
Requirements for unified, structured application configuration in Vultron. Configuration uses YAML files backed by Pydantic-based models loaded via pydantic-settings with environment variable override support.
File ID: CFG
Module Placement and Config API
| ID | Priority | Requirement | Related |
|---|---|---|---|
CFG-01-001 |
MUST | The application MUST provide a top-level neutral package vultron/config/ whose app.py module contains AppConfig, get_config(), and reload_config(). |
|
CFG-01-002 |
MUST | get_config() MUST return a cached AppConfig instance — loaded once on first call, cached for all subsequent calls. |
|
CFG-01-003 |
MUST | reload_config() MUST clear the cached instance so the next get_config() call re-reads configuration from the file and environment. |
|
CFG-01-004 |
MUST | All code that previously called os.environ.get() for Vultron application settings MUST be updated to call get_config() instead. |
|
CFG-01-005 |
MUST | AppConfig MUST be injectable in FastAPI routers via Depends(get_config). |
|
CFG-01-006 |
MUST NOT | vultron/config/ MUST NOT import from adapter-layer or FastAPI modules; it is a neutral shared module usable by all layers. |
Refines: ARCH-05-001 — core and neutral modules have no adapter-layer imports |
Config File Format and Loading
Environment Variable Naming
AppConfig Structure
SeedConfig Alignment
Testing
| ID | Priority | Requirement | Related |
|---|---|---|---|
CFG-06-001 |
MUST | Config loading MUST be testable using pytest's monkeypatch.setenv() to inject environment variable overrides without modifying the process environment permanently. |
|
CFG-06-002 |
MUST | Config loading MUST be testable using pytest's tmp_path fixture to supply a temporary YAML config file. |
|
CFG-06-003 |
MUST | Test fixtures MUST clear the config cache between test cases by calling clear_config_cache() (which drops the cached config without loading a new one) in teardown — NOT by calling reload_config(). Calling reload_config() in teardown triggers an immediate cache reload before pytest's monkeypatch reverts environment-variable changes, which locks in the test's environment state for the reload and pollutes the next test. Clearing the cache defers the reload until the next get_config() call, by which time monkeypatch has already restored the environment.reload_config() = reset + immediate reload. In teardown the env is still the test's env, so the reload bakes that env into the next test's starting state. clear_config_cache() = reset only; reload happens lazily under clean env. The cache is owned by functools.cache on get_config(), not a module global (#3985). |
|
CFG-06-004 |
MUST | Tests MUST verify that environment variables override YAML values for the same field. | |
CFG-06-005 |
MUST | Tests MUST verify that the application starts successfully with all defaults (no file, no env vars set). | |
CFG-06-006 |
MUST | vultron/config/app.py MUST export a config_override(**env_updates) context manager that atomically sets the given environment variables, calls reload_config(), yields the updated AppConfig, and then restores the original environment values and calls reload_config() again — including when the body raises an exception (i.e., the restore MUST happen in a finally block).Calling reload_config() in fixture teardown while env patches are still active bakes stale values into the config cache for subsequent tests (ISSUE-2086, CONCERN-2323). config_override() owns both the env mutation and the cache lifecycle, making the incorrect reload-before-undo ordering impossible by construction. |
|
CFG-06-007 |
SHOULD | Test fixtures that need to override AppConfig values SHOULD use config_override() instead of combining monkeypatch.setenv() with explicit reload_config() calls. Where monkeypatch is used directly (e.g., in session-scoped fixtures), monkeypatch.undo() MUST precede any reload_config() call in teardown (CFG-06-003).config_override() (CFG-06-006) eliminates the ordering footgun: the correct undo-then-reload sequence is impossible to get wrong. The explicit ordering rule is still required for code that cannot adopt config_override(). |
Refines: CFG-06-003 — config_override() is the preferred mechanism; CFG-06-003 applies when it cannot be used |
Actor Configuration
| ID | Priority | Requirement | Related |
|---|---|---|---|
CFG-07-001 |
MUST | A neutral ActorConfig Pydantic model MUST be defined outside the demo layer (e.g., in vultron/core/models/ or vultron/config/actor.py) so it can be used by BT nodes, API adapters, and CLI entry points without importing from vultron/demo/.LocalActorConfig (in vultron/demo/seed_config.py) is demo scaffolding. BT nodes that need actor role information MUST NOT import from the demo layer (BTND-04-002). A neutral ActorConfig provides the same information without the layering violation. |
|
CFG-07-003 |
SHOULD | LocalActorConfig in vultron/demo/seed_config.py SHOULD compose or extend ActorConfig so that the seed actor configuration includes CVD role defaults without duplicating the field definition. |
|
CFG-07-004 |
MUST | BT nodes that create a case-owner participant record for the local actor MUST source the actor's CVD roles from ActorConfig.default_case_roles, not from a hardcoded constant. |
|
CFG-07-005 |
MUST | A load_actor_config() function MUST exist in vultron/config/ (the neutral config sub-package).PR #1331 introduced a direct SeedConfig import in vultron/adapters/driving/fastapi/inbox_port_factories.py, making vultron/demo/ a de-facto production dependency (concern #1334). A neutral load_actor_config() in vultron/config/ severs that dependency without losing YAML seed-file support. |
|
CFG-07-006 |
MUST | CVDRole and its associated helpers (serialize_roles, validate_roles) MUST reside in a top-level neutral enum module (vultron/enums/roles.py) rather than inside vultron/core/, so that vultron/config/ and other neutral modules can import them without violating the constraint that neutral modules MUST NOT import from vultron/core/.ActorConfig in vultron/config/actor.py needs CVDRole. If CVDRole stays in vultron/core/states/roles.py, vultron/config/ must import from core — a layering violation. Moving CVDRole to the top-level neutral layer resolves this for all layers simultaneously. |
|
CFG-07-007 |
MUST | LocalActorConfig in vultron/demo/seed_config.py MUST be decoupled from ActorConfig.LocalActorConfig currently extends ActorConfig, mixing bootstrap identity data with runtime policy. In the target architecture, identity data seeds the DataLayer once at startup while policy is read at dispatch time from AppConfig.actor. Decoupling the two models reflects this distinct lifecycle and prevents demo scaffolding from leaking policy concerns into production paths. |
|
CFG-07-008 |
MUST | A YAML sub-block loader (e.g., _actor_config_from_seed_yaml()) MUST check for the presence of the target key in the raw YAML dict before calling model_validate. If the key is absent, the loader MUST return None so the caller falls through to the next source in the resolution order (env vars, then defaults).pydantic.model_validate({}) on a model whose every field has a default succeeds silently and returns an all-defaults instance. This makes it impossible to distinguish "key absent from YAML" from "key explicitly set to defaults", causing load_actor_config() to return early and skip VULTRON_ACTOR__* env vars — violating the documented YAML → env → defaults resolution order (CFG-03-001). The fix is a key-presence check before model_validate, not a post-validate comparison. |
Refines: CFG-03-001 — resolution-order guarantee requires key-presence check, not value comparison |
CFG-07-009 |
MUST | load_actor_config() MUST return an ActorConfig by reading VULTRON_SEED_CONFIG YAML (extracting local_actor fields with extra="ignore" to discard demo-only fields) or falling back to VULTRON_ACTOR_* environment variables.Defines the resolution order (YAML seed file then env vars then defaults) so that both seed-file and environment-variable-only deployments are supported. |
|
CFG-07-010 |
MUST | Production adapter modules MUST import load_actor_config() from vultron/config/ rather than importing from vultron/demo/ to obtain actor policy configuration.Prevents the demo layer from becoming a de-facto production dependency, as introduced by concern #1334. |
|
CFG-07-011 |
MUST | LocalActorConfig MUST carry only demo/bootstrap fields (name, actor_type, id_).Restricting LocalActorConfig to identity bootstrap fields ensures policy concerns do not leak into demo scaffolding. |
|
CFG-07-012 |
MUST NOT | LocalActorConfig MUST NOT extend ActorConfig; actor policy fields (auto_create_case, default_case_roles) belong in AppConfig.actor and are loaded via load_actor_config().Extending ActorConfig entangles bootstrap identity with runtime actor policy, violating the distinct lifecycle separation described in CFG-07-007. |
DataLayer Port
Behavioural contract that all DataLayer adapter implementations MUST satisfy. Covers auto-rehydration on read, type-safe write operations, and port isolation.
File ID: DL
Auto-Rehydration on Read
Type-Safe Write Operations
Port Isolation
| ID | Priority | Requirement | Related |
|---|---|---|---|
DL-03-001 |
MUST | The DataLayer port (vultron/core/ports/datalayer.py) MUST be defined entirely in terms of core domain types and MUST NOT import from vultron/wire/, vultron/adapters/, or any storage-specific library (SQLModel, TinyDB, etc.).Enforces hexagonal architecture layer separation (ARCH-01-001). |
Implements: ARCH-01-001 |
DL-03-002 |
MUST | The DataLayer adapter MUST NOT expose vocabulary registry lookups or AS2 type-name resolution to core; the vocabulary registry is a wire-layer concern and the adapter must maintain its own internal mapping. Prevents wire-layer types from leaking into the core domain through the persistence port. |
Narrow Core Persistence Ports
| ID | Priority | Requirement | Related |
|---|---|---|---|
DL-04-001 |
MUST | The DataLayer port MUST remain the full adapter-level persistence contract, while core use cases and BT nodes MUST depend on CasePersistence or CaseOutboxPersistence instead of the full DataLayer port unless a documented exception requires the broader interface.Preserves a complete adapter-facing contract while making narrow, intention-revealing ports the default dependency for core code. |
Implements: ARCH-01-001 |
DL-04-002 |
MUST | The current required minimum CasePersistence surface is actor_id, clone_for_actor, create, read, read_case, save, save_many, transaction, list_objects, find_case_by_report_id, find_actor_by_short_id, find_case_by_short_id, find_protocol_pair, and delete; future additions MAY be made only when they preserve the same core-facing persistence/query boundary and do not introduce queue, admin, health, or diagnostic responsibilities. actor_id and clone_for_actor are part of the minimum because a store belongs to exactly one actor (DL-07-002) and reaching another's is a named operation (DL-07-005). read_case is the canonical typed accessor for VulnerabilityCase retrieval — callers prefer it over read() + isinstance narrowing (ISSUE-2490).Makes the current narrow-port contract explicit without freezing the interface against future legitimate query helpers. The enumeration is the declared Protocol, so a drift between it and vultron/core/ports/case_persistence.py is a spec defect rather than a code one. get and by_type were listed here until ADR-0073 and are deliberately absent now: they are not declared on the Protocol and have no remaining core call site (see DL-04-005). |
|
DL-04-003 |
MUST NOT | CasePersistence MUST NOT include inbox queue methods, outbox inspection or drain methods, ping, clear_*, update, get_all, all, count_all, or other infrastructure, admin, or diagnostic operations that belong to the full DataLayer adapter contract.Prevents low-level storage and operational concerns from leaking into core use cases and BT nodes. delete was on this prohibition list while simultaneously being declared on the Protocol and called from core BT nodes to remove records the core itself wrote. Removing such a record is a domain operation, not an admin one, so delete moved to DL-04-002. update stays prohibited: core code mutates through save, which is idempotent by object id. |
|
DL-04-004 |
MUST | CaseOutboxPersistence MUST extend CasePersistence only with the outbound enqueue method outbox_append; if a ReceivedUseCase depends on CaseOutboxPersistence, that dependency MUST be treated as a documented architectural smell rather than normal design.Makes outbound side effects explicit and highlights mixed inbound-plus-broadcast handlers for later refactoring. record_outbox_item(actor_id, ...) was removed from this surface by ADR-0073: it existed so that an unscoped DataLayer could name the actor explicitly, and every one of its call sites passed the executing actor's own id, so under a mandatory actor scope it was exactly outbox_append. Keeping both would have preserved a way to write to a queue that is not the store's own. |
|
DL-04-005 |
MUST | get() and by_type() MUST NOT be declared on CasePersistence; core code MUST use typed read(), list_objects(), or dedicated typed helper methods. Any reintroduction of a raw-record escape hatch on the narrow port MUST be recorded as a deliberate decision rather than added incidentally.The narrow port converges on typed domain-object access rather than preserving raw-record escape hatches. This entry used to describe get/by_type as deprecated-but-present; the migration it called for has completed — neither is declared on the Protocol and neither has a call site under vultron/core/ — so the requirement is now the prohibition rather than the migration. |
Core-Object Read Contract
The DataLayer read path MUST return core domain objects for every persisted type that has a registered core counterpart. Wire-layer vocabulary types MUST NOT be handed to core code through the persistence port.
| ID | Priority | Requirement | Related |
|---|---|---|---|
DL-05-001 |
MUST | dl.read(id) and dl.list_objects(type_key) MUST reconstruct persisted domain entities using the core vocabulary registry (vultron.core.models.registry.CORE_VOCABULARY) so that the returned instance is a vultron.core.models type (e.g. core VulnerabilityCase), never a vultron.wire.as2.vocab type (e.g. as_VulnerabilityCase), whenever the persisted type_ has a registered core counterpart.Core code MUST receive core domain objects so that core validators run and static analysis sees the real type (ARCH-01-002). Reconstructing via the wire VOCABULARY returns wire objects that only work in core by structural coincidence of field names. Decided in ADR-0034; derived from ARCH-01-002 and DL-03-002. |
Implements: ARCH-01-002 |
DL-05-002 |
MUST | The DataLayer adapter MUST own the wire↔core translation for persistence. The wire registry is a wire-layer concern (DL-03-002). Making the adapter own translation decouples DataLayer storage from the wire type system so the wire layer can evolve without breaking persistence. |
Implements: DL-03-002 |
DL-05-003 |
MUST NOT | Core code (vultron/core/) MUST NOT use structural duck-typing Protocols to accept DataLayer results without importing concrete core types. Core MUST depend on concrete domain classes and use isinstance narrowing directly. Structural workaround Protocols that evade ARCH-01-001 are prohibited.The duck-typing Protocols exist solely to let core touch wire objects at runtime without a compile-time wire import, evading rather than honouring ARCH-01-001. When the read path returns core objects, the Protocols are dead weight and mask the boundary from mypy/pyright. |
Implements: ARCH-01-001 |
DL-05-004 |
MUST | An architecture ratchet test MUST assert that no vultron.wire.as2 vocabulary type is returned from dl.read() / dl.list_objects() into vultron/core/; the test MAY exempt persisted AS2 Activity types that have no registered core counterpart, and any such exemption MUST be enumerated explicitly so the exemption set can only shrink.Prevents regression back to wire-object leakage. Activity read-back from core is itself a boundary violation tracked separately; the enumerated exemption set documents the remaining debt and ratchets toward zero. |
|
DL-05-005 |
MUST | Wire→core normalisation on the persistence write path MUST use CORE_VOCABULARY lookup (DL-05-006, ARCH-23-002), not a hand-maintained _NORMALIZE_WIRE_TO_CORE list in vultron/adapters/driven/db_record.py. Once wire and core registry keys are disjoint (ARCH-23-002) no wire class shadows a CORE_VOCABULARY entry, so the subject set of the former grow-only ratchet is permanently empty; that list and its ratchet test MUST be deleted rather than maintained.The invariant is unchanged — a wire-shaped row MUST NOT be persisted in a core-typed DataLayer table (source concern #2268) — but the mechanism moves from a hand-maintained list to a structural property. _NORMALIZE_WIRE_TO_CORE was the ADR-0062 persistence-boundary backstop, needed only because fifteen wire classes shadowed a core type. ADR-0082 planned to remove the shadowing outright and ADR-0099 did, by deleting the paired wire classes (ARCH-25-003), which makes the list dead code: a grow-only ratchet over an empty subject set can never fail, and a list somebody must remember to update is the divergence mechanism behind #1991 → #2232 → #2260 → #2268. |
Implements: DL-05-001 Depends on: ARCH-23-002 — Disjoint registry keys are what empty this requirement's former subject set. |
DL-05-006 |
MUST | The DataLayer adapter MUST maintain its own type_→core-class mapping (via CORE_VOCABULARY) that is independent of the wire VOCABULARY registry.An independent adapter-owned mapping decouples DataLayer storage from the wire type system so the wire layer can evolve without breaking persistence (DL-03-002). |
|
DL-05-007 |
MUST NOT | The wire vocabulary registry MUST NOT be the mechanism by which core objects are reconstructed on the DataLayer read path. Using the wire registry for core object reconstruction leaks wire-layer concerns into the adapter read path, violating DL-03-002. |
|
DL-05-008 |
MUST | The write path MUST NOT retain a deferral list of not-yet-normalised types. The companion _NOT_YET_NORMALIZED enumeration in vultron/adapters/driven/db_record.py MUST be deleted together with _NORMALIZE_WIRE_TO_CORE (DL-05-005).The original completion condition — that _NOT_YET_NORMALIZED reach empty — is satisfied structurally rather than by migration bookkeeping. With disjoint registry keys (ARCH-23-002) there is no shadowing type left to defer, and extra="forbid" (ARCH-12-003) turns a mis-shaped row into a loud failure at read-back. An empty enumeration left in the source is an invitation to add the next entry to it. |
Depends on: DL-05-005 — The deferral list has no purpose once the normalisation list it defers against is gone. |
DL-05-009 |
MUST | The closed-form guarantee that no wire-shaped row slips into a core-typed table MUST be carried by the structural invariants — disjoint registry keys (ARCH-23-002) plus extra="forbid" on every core-branch type (ARCH-12-003) — rather than by a per-type coverage ratchet over _NORMALIZE_WIRE_TO_CORE.The closed-form property is retained; what changes is what closes it. A per-type coverage ratchet is closed only for the types someone enumerated, so a new shadowing type is a silent gap until the list is updated. Disjointness makes the shadowing impossible to create, and extra="forbid" makes an un-normalised row raise instead of round-tripping with keys dropped — an invariant the type system holds rather than a list somebody maintains. |
Depends on: ARCH-12-003 — extra="forbid" is the read-back guard that replaces per-type coverage bookkeeping. |
Activity Read-Back and the Semantic/Envelope Split
Core code MUST NOT read a persisted wire AS2 Activity back from the DataLayer to recover its semantic content. AS2 protocol Activities are wire-only envelopes with no registered core counterpart (the DL-05-004 exemption set); the domain fact each carries MUST live as core state. A received activity is archived as the body that arrived, and two reads of it are sanctioned: core reads its facts from the read-only view a load of that body yields, and a wire/adapter-owned seam parses it to embed it in a reply. Decided in ADR-0035, partially superseded by ADR-0149; extends DL-05 and ARCH-09-001.
| ID | Priority | Requirement | Related |
|---|---|---|---|
DL-06-001 |
MUST NOT | Core code (vultron/core/) MUST NOT call dl.read(activity_id) or dl.list_objects(<activity_type>) to recover the semantic content of a persisted wire AS2 Activity (e.g. reading a stored Offer to obtain the embedded report, or a stored Invite to obtain its case or embargo id). Core MUST read a received activity's facts only from the read-only view a load of its archived body yields (the ReceivedActivityLoader port, ADR-0149), and every other domain fact from core state.AS2 protocol Activities are wire-only envelopes with no core counterpart, so reading one back into core hands a vultron.wire.as2 type to core code (ARCH-01-002) and re-interprets an activity the semantic extractor already interpreted once (ARCH-03-001). The activity is being used as the system of record for a domain fact, inverting the rule that wire is a projection of core (ARCH-09-001). The archive's loaded view is the exception, and the only one: it is the semantic extractor's own output for the archived body, built by the one interpretation site, so it re-interprets nothing and hands core no wire type (ADR-0149). Decided in ADR-0035; source concern #1506. |
Implements: ARCH-09-001 Implements: ARCH-03-001 |
DL-06-002 |
MUST | Every domain fact an actor must remember from a received protocol message MUST be recorded as core state — either a state transition on an existing core entity or a purpose-built core record — at the point the semantic extractor first interprets the inbound activity. Core MUST NOT reconstruct such a fact by re-reading a stored activity later. Reading the read-only view a received activity's archive loads (DL-06-001) is not such a re-read. Core is the sole system of record for domain facts (the Actor Knowledge Model bounds an actor's knowledge to what it has received, and that knowledge is held as core state). Capturing the fact once at extraction time removes the need for core to reach back through the wire envelope. This does NOT mean cloning each AS2 Activity into a 1:1 core Activity class; model only the domain fact each message conveys, in domain vocabulary, capturing only what handlers use. The archive's loaded view is the extraction itself, run again on the archived body, so reading it reaches back through no envelope (ADR-0149). Decided in ADR-0035. |
Implements: ARCH-03-001 |
DL-06-003 |
MUST | When an inbound protocol message references a prior message (e.g. an Accept answering an Invite, a counter-offer answering an Offer), core MUST correlate them through a core-entity relationship (e.g. "the pending embargo proposal for this case", "the invitation for this actor"), NOT by re-reading the referenced wire activity from the DataLayer. Correlation is a domain relationship between core facts. Recovering it by re-reading the referenced activity reintroduces the DL-06-001 violation and depends on the wire envelope surviving as a semantic record. When the prior message was first received it MUST have created or updated the core entity that the later message now correlates to. Decided in ADR-0035. |
|
DL-06-004 |
MUST | Reconstituting the verbatim original activity for an outbound reply's inline object_ / in_reply_to field (as the Actor Knowledge Model requires) MUST read the received body the receiver archived and parse it in the wire/adapter layer, and the reply MUST embed that body as received, every key included; core code MUST NOT read the body to interpret its meaning. Such a wire/adapter-owned seam is NOT counted as a core boundary violation under DL-05-004.Activity IDs are non-regenerable random urn:uuid: values and the Actor Knowledge Model requires a reply to embed the full inline original activity, so verbatim reconstitution cannot be produced by re-projecting core state — the original envelope (with its original id) must be retained and read back. Confining that read to a wire/adapter seam that never interprets the payload keeps semantic authority in core while satisfying transport needs. A re-dump of a parsed or extracted form is not the original: the parse sets aside keys the vocabulary does not name, and the extractor keeps only what core reads, so the reply embeds the archived body itself (ADR-0149). CaseLedgerEntry payloadSnapshot is the existing precedent for opaque, write-only activity retention. Decided in ADR-0035. |
Refines: DL-05-004 |
DL-06-005 |
MUST | As each core semantic read is migrated off activity read-back (per DL-06-001 through DL-06-003), the corresponding AS2 Activity type MUST be removed from the DL-05-004 ratchet exemption set. The exemption set MUST reach zero once all core semantic activity reads are migrated; the only remaining sanctioned activity reads are the wire/adapter envelope-reconstitution seam of DL-06-004 and core's reads of an archive's loaded view (DL-06-001). Ties the migration to an enforceable, monotonically shrinking ratchet so progress is measurable and regressions are blocked. Decided in ADR-0035; source concern #1506. |
Refines: DL-05-004 |
DL-06-006 |
MUST NOT | A receiver MUST NOT hold a received activity in its DataLayer as a record readable by the id the sender chose for it, at any stage: not at inbox ingress, not for a deferred replay, and not in a received-side tree. A received activity is held only under a key the receiver derives from the activity id: its ReceivedActivityRecord.build_id, or the opaque deferred record that holds it for replay (DeferredInboxActivity.build_id, IO-03-004). A record of an activity type stored under its own id is then always one the receiver authored.An actor scans the type tables holding its sent activities to ask "did I already send this?" (was the case announced, which stub Invites did it issue). A received copy under the sender's id answers yes, even one whose unsigned actor names the receiver (#2841). CLP-10-017 keeps intake off the sender's id for this reason; this extends it to every writer (#4866). |
Refines: CLP-10-017 Refines: IO-03-004 |
Per-Actor Store Isolation
| ID | Priority | Requirement | Related |
|---|---|---|---|
DL-07-001 |
MUST | Each actor MUST have its own store. A DataLayer instance MUST belong to exactly one actor, and one actor's writes MUST NOT be able to affect what another actor could read — including writes to the same object ID, which two actors MAY each hold their own replica of. This is CM-01-001's isolation requirement restated as a property of the storage layout rather than of each query. Previously actor_id was an indexed filter column over a globally unique primary key, so reads were partitioned and writes were not: save() on one actor's DataLayer overwrote another actor's row while leaving it owned by the original actor, and create() raised a spurious duplicate error for the second actor. Both are unreachable when the layout — not the WHERE clause — carries the isolation. Decided in ADR-0073; source issue #2238. |
Implements: CM-01-001 |
DL-07-002 |
MUST | A DataLayer MUST NOT be constructible without an actor. There MUST be no shared, unscoped, or "admin" DataLayer, and no factory MUST return an instance whose actor is unset. Makes DL-07-001 unreachable to violate rather than merely discouraged. An unscoped mode is not a neutral convenience: deps.py demonstrated that it accumulates domain data ("operational data — actors, offers, reports, cases — is stored in the shared DataLayer"), so anything holding one could read across actors. Guarantees that depend on every call site remembering to scope had already failed once here, producing two architecture ratchets for one problem (#2232). |
Refines: DL-07-001 |
DL-07-003 |
MUST | The configured db_url MUST be treated as a template rather than a location: each actor's store MUST be derived from it per actor. An already-derived URL MUST NOT be passed back through the derivation.A single configured location cannot express one store per actor. Derivation is not idempotent and cannot be made so, because a suffix cannot be stripped unambiguously — app7-vendor is indistinguishable from app7 plus vendor — so re-deriving silently produces a third store. |
Refines: DL-07-002 |
DL-07-004 |
MUST | An in-memory store MUST be named such that its identity is carried entirely by its URL, not by the identity of an engine object held in a cache. With identity in a Python object, two independent applications in one process that both host the same actor silently shared one store — the URL said :memory: for both and only the cache distinguished them. Naming the database makes the URL the single source of store identity in both file-backed and in-memory modes. Two consequences follow for tests and MUST be respected: two DataLayers built for the same actor id are the same database, and two scenarios that must not see each other's rows therefore need two actor ids. |
Refines: DL-07-002 |
DL-07-005 |
MUST | Cross-actor access MUST be named at the call site. clone_for_actor MUST be the only route to another actor's store, and any node-wide view MUST be assembled by enumerating hosted actors and fanning out rather than obtained from a single query.Makes a fan-out explicit rather than something a forgotten filter grants. CasePersistence declares clone_for_actor, so a cross-actor read is visible in the type as well as in the code. |
Refines: DL-07-001 |
DL-07-006 |
MUST | A URL path segment naming an actor MUST be resolved to a canonical actor URI by computation from the node's own base URL, without a registry lookup or a scan over stored actor records. A scan is a cross-actor read, and it could return a peer's record — an actor this node does not host — because since DL-07-007 a hosted actor's store is full of peer records. Computation also means only an actor whose id already has this node's shape is addressable here, which is correct: an id under another authority names a process elsewhere. |
Refines: DL-07-005 |
DL-07-007 |
MUST | A peer actor's record MUST live in the address book of each hosted actor that knows it, not in a node-global store. GET /actors/ MUST return only the actors the node hosts.An actor's view of its peers is that actor's own data, per the Actor Knowledge Model: what an actor knows is bounded by what it has received. This is safe because delivery derives a recipient's inbox URL from its URI alone and never reads the peer's stored record. A peer is not something the node hosts; it is an address some hosted actor happens to know. |
Refines: DL-07-001 |
DL-07-008 |
MUST | Retry and dead-letter bookkeeping for an actor's outbox MUST live in that actor's own store and MUST NOT be keyed by an actor column in a shared table. Delivery bookkeeping about an actor's own outbox is that actor's own data, so it is subject to DL-07-001 like any other row. Keying it on (actor_id, activity_id) in a shared table reintroduces exactly the filter-column layout DL-07-001 replaces; keying on activity_id alone within the actor's store also makes an attempt counter structurally single-valued, so one activity cannot accumulate two rival counts. |
Refines: DL-07-001 Refines: OX-13-001 |
DL-07-010 |
MUST | POST /actors/ MUST reject with 422 an absolute actor_id whose authority (host and port) differs from that of the node's configured base URL, and MUST NOT create a store or actor record for it. A peer actor MUST instead be registered through POST /actors/{actor_id}/peers/, which writes the peer's actor record into the store of the hosted actor named in the path (DL-07-007), answers 404 when that path names no actor this node hosts, and is idempotent: registering a peer id that the hosted actor already holds returns the existing record rather than a second one.Minting a store for every posted id made the node claim to host actors living elsewhere, and two foreign ids sharing a final path segment collided on one store, so the second silently got the first actor's record (#2549). Rejecting foreign authorities at the route, and registering peers into the knower's own store, keeps hosting and knowing apart. Recorded in ADR-0081. |
Refines: DL-07-007 |
Demo CI — GitHub Actions Demo Integration Workflow
Requirements for a GitHub Actions workflow that builds and runs Vultron demo containers on pull requests, and for hardening the demo runner to exit non-zero when the demo fails — closing the gap where tests pass while the demo produces silent failures. Includes requirements for the invariant harness to run as an independent CI gate so failures are visible even when the demo itself also fails.
File ID: DEMOCI
Demo Runner Failure Signalling
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-01-001 |
MUST | The demo runner MUST exit with a non-zero exit code when the demo scenario fails, regardless of whether the Python process itself raised an unhandled exception. docker compose --exit-code-from demo-runner requires a non-zero exit to surface failures in CI; a process that logs errors but exits 0 is indistinguishable from a successful run at the CI level. |
|
DEMOCI-01-002 |
MUST | The demo runner MUST treat any HTTP 404 response from an actor endpoint as a demo failure. A 404 from an actor inbox or trigger endpoint means the target route does not exist, which is a protocol error, not a transient condition. |
|
DEMOCI-01-003 |
MUST | The demo runner MUST accumulate all errors encountered during the scenario and raise a typed DemoFailureError at the end of the scenario run, rather than halting at the first error. Accumulating errors produces a complete diagnostic picture of all failures in a single run; stopping at the first error obscures subsequent steps that may also be failing. |
|
DEMOCI-01-004 |
MUST | Error detection and accumulation MUST be implemented by the demo_step, demo_check, and demo_gate context managers in vultron/demo/utils.py. All demo workflow steps and verification blocks already use these context managers; centralising error accumulation there minimises the change surface. demo_gate was added by DEMOCI-01-007: it accumulates identically, and differs only in that it also stops the steps that depend on the failed precondition. |
|
DEMOCI-01-005 |
MUST | DemoFailureError MUST be a subclass of VultronError and MUST be defined in vultron/errors.py. | Refines: EH-01-001 — error-handling.yaml |
DEMOCI-01-006 |
MUST | Every scenario entry-point function MUST call reset_demo_failures() as its first action and MUST call assert_demo_success() as its final action before returning. These two calls form the bookend contract that initialises the failure accumulator and converts accumulated failures into a DemoFailureError for CI detection.DEMOCI-01-003 and DEMOCI-01-004 specify the accumulation mechanism, but do not specify where the accumulator is initialised or where the final assertion is made. Without explicit bookends, a scenario may inherit failure state from a previous run (if not reset) or exit without surfacing accumulated failures (if assert_demo_success() is not called). |
Refines: DEMOCI-01-003 Refines: DEMOCI-01-004 |
DEMOCI-01-007 |
MUST | A causal precondition MUST be expressed with a demo_gate context manager, distinct from demo_check.Accumulate-and-continue is correct for verification assertions — report every failed check, not just the first — and wrong for preconditions, where continuing buries the real failure under secondary ones and makes the gate indistinguishable from a comment. Both behaviours are needed, so they need distinct names. See ADR-0058 and EDF-06-005. |
Refines: DEMOCI-01-003 — Constrains control flow only; the accumulation and exit-code contract of DEMOCI-01-001 and DEMOCI-01-003 is unchanged. Implements: EDF-06-005 |
DEMOCI-01-008 |
MUST | On failure, demo_gate MUST record the failure in the accumulator exactly as demo_check does.Gate failures must be accumulated so the exit-code contract of DEMOCI-01-001 and DEMOCI-01-003 applies uniformly, regardless of whether the failed check is a precondition or an advisory assertion. |
|
DEMOCI-01-009 |
MUST | On failure, demo_gate MUST prevent execution of the steps that depend on the unmet precondition.Continuing past a failed precondition buries the root cause under secondary failures from steps that assumed the precondition was met. |
|
DEMOCI-01-010 |
MUST | demo_check MUST remain advisory: it records a failure in the accumulator and continues execution.Advisory accumulation is the correct behaviour for verification assertions — every failed check should be reported in a single run rather than stopping at the first one. |
|
DEMOCI-01-011 |
MUST | A scenario MUST NOT let a raising poll or verification helper's timeout escape the failure accumulator. Any wait_for_* (or equivalent verification) call that raises on timeout MUST execute inside a demo_step, demo_check, or demo_gate context, OR be invoked through a shared helper that wraps the raising call in one of those contexts internally. A shared helper that encapsulates such polling MUST perform the wrap itself so that every caller inherits it.DEMOCI-01-003 requires errors to accumulate and surface together; a bare raising wait defeats that by crashing the scenario at the first failure, burying every co-occurring failure behind one misleading signal. This has recurred twice — bare wait_for_contiguous_ledger_coverage (#1772/#1802) and bare wait_for_participants_on_replicas (#3384) — so the constraint is stated explicitly and enforced by an architecture ratchet rather than reviewer vigilance. Wrapping inside the shared helper (as wait_for_participants_on_replicas and wait_for_replica_ledger_coverage do) fixes every caller at once. |
Refines: DEMOCI-01-003 — Constrains where raising waits may run; the accumulation and exit-code contract of DEMOCI-01-001 and DEMOCI-01-003 is unchanged. |
GitHub Actions Demo Workflow
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-02-001 |
MUST | A dedicated GitHub Actions workflow file (.github/workflows/demo-integration.yml) MUST exist for running the demo integration tests. It MUST NOT be merged into python-app.yml. Keeping the demo workflow separate avoids adding Docker-compose build time to the existing linting and unit-test pipeline and allows the two workflows to run concurrently on PRs. |
|
DEMOCI-02-002 |
MUST | The demo workflow MUST trigger on pull_request events targeting the main branch, on push events to the main branch (see DEMOCI-05), and on workflow_dispatch events. The pull_request trigger validates the ephemeral merge commit before a PR lands. The push-to-main trigger validates the actual merged main HEAD after a PR lands, closing the "green PRs, red main" gap where two individually-green PRs interact to break main with no post-merge signal (see DEMOCI-05). |
|
DEMOCI-02-003 |
MUST | Both the pull_request and push triggers MUST include the same paths filter, covering: vultron/ (excluding vultron/metadata/ and vultron/bt/), docker/, integration_tests/, test/ci/, pyproject.toml, uv.lock, .github/workflows/demo-integration.yml, .github/actions/build-demo-images/, and .github/demo-scenarios.json. *These are the files whose changes can affect demo container behaviour or the invariant assertions that validate it. Filtering avoids running the expensive workflow on pure docs or spec changes. The push and pull_request filters MUST match so that any change validated pre-merge is also validated post-merge on main. vultron/metadata/ is excluded because it contains only tooling (spec/ADR/notes loaders and CLI entry points) that is never imported by actor services or the demo runner container at runtime. vultron/bt/ is excluded because the demo layer is architecturally prohibited from importing the legacy simulation layer (enforced by test/architecture/test_demo_no_bt_imports.py). .github/workflows/demo-integration.yml and .github/actions/build-demo-images/ are included so that changes to the workflow or the shared build action itself trigger a validation run. .github/demo-scenarios.json is included because it is the file the matrix is resolved from (DEMOCI-06-004), so a change to it changes the demo matrix itself. Since ADR-0098 that file is a generated projection of the scenario registry rather than the source of truth (DEMOCI-11), but it remains in the filter: a registry change regenerates the JSON in the same commit, so filtering on the JSON still detects every scenario change.* |
|
DEMOCI-02-004 |
MUST | The demo workflow job MUST be skipped for Dependabot pull requests, detected via the condition if: "!startsWith(github.head_ref, 'dependabot/')".Dependabot PRs arrive in waves (e.g., weekly dependency bumps) and are unlikely to break the demo; running the full container stack on each one wastes CI resources. The python-app.yml test suite still guards against broken dependencies on Dependabot PRs — which holds only because its path filter includes uv.lock (DEMOCI-02-016). |
|
DEMOCI-02-005 |
MUST | The demo workflow MUST build the Docker images using docker/docker-compose-multi-actor.yml and run the rv demo scenario (DEMO=rv) as the initial CI scenario. | |
DEMOCI-02-006 |
MUST | Demo failure MUST be detected via the exit code of the demo-runner container, using the docker compose flag --abort-on-container-exit --exit-code-from demo-runner. |
|
DEMOCI-02-007 |
SHOULD | The workflow SHOULD use Docker layer caching to avoid rebuilding unchanged dependency layers on every run. GHA cache export via composite run: steps is inoperable: GitHub only injects the required cache tokens (ACTIONS_RUNTIME_TOKEN, ACTIONS_CACHE_URL) into uses: steps, not run: steps. The GHA cache configuration has been removed until a working mechanism is available (see #2248). |
|
DEMOCI-02-008 |
MUST | The workflow MUST upload all container logs as a CI artifact when the demo job fails or is cancelled, and MUST NOT upload logs on success. Logs are diagnostic information; uploading them only on failure or cancellation keeps successful run artifacts clean while providing full context for debugging broken or timed-out runs. A cancelled job (e.g. caused by a wedged container hitting the job timeout) converts a diagnosable failure into an undiagnosable one unless logs are collected on the cancelled path (#2245). |
|
DEMOCI-02-009 |
MUST | The demo workflow job MUST have a timeout of 15 minutes (timeout-minutes: 15). The rv demo currently takes 3–5 minutes; a 15-minute cap prevents an indefinite CI hang if a container gets stuck, while providing sufficient headroom for slow runners. |
|
DEMOCI-02-010 |
MUST | The workflow MUST include a teardown step that runs docker compose down -v after the demo job (success or failure) to release Docker volumes and stop all containers.GitHub Actions self-hosted runners and persistent environments accumulate stale Docker state if teardown is skipped. |
|
DEMOCI-02-011 |
MUST | The demo-integration.yml workflow MUST define a concurrency group keyed on ${{ github.workflow }}-${{ github.ref }}.When commits land quickly during iterative work, queued demo runs pile up and waste CI minutes while delaying feedback. A single concurrency group keyed on workflow + ref collapses bursts on any branch to the newest run. On main a 60-second debounce step (DEMOCI-02-014) throttles burst merges without requiring per-commit granularity — a time-range bisect across a typical 4–6 PR batch is sufficient to identify regressions. |
|
DEMOCI-02-013 |
MUST | The concurrency group MUST set cancel-in-progress: true on all triggers so that a newer push always cancels superseded in-progress and queued runs.Cancelling superseded runs reclaims CI minutes and reduces feedback latency on both PR branches and main. Burst-merge throttling on main is handled by the debounce action (DEMOCI-02-014) rather than by suppressing cancellation. |
|
DEMOCI-02-014 |
MUST | The .github/actions/debounce-main-push composite action MUST be invoked immediately after actions/checkout in each job that performs expensive work (the scenarios job in demo-integration.yml, and each stage-1 job in python-app.yml). The action sleeps 60 seconds only when github.event_name == 'push' and github.ref == 'refs/heads/main'; PR and workflow_dispatch runs are unaffected.With cancel-in-progress: true on all triggers, a burst of merges to main would each spin up a full runner fleet and immediately cancel the previous one, leaving only the last at full cost. The sleep ensures that rapid successive pushes cancel each other before any real work starts, so only the last commit in a burst actually pays the full suite cost. The guard on github.ref prevents the sleep from ever firing on PR or dispatch runs. |
|
DEMOCI-02-015 |
MUST | The cancel-in-progress field MUST be the boolean literal true (not an expression). Per-trigger differentiation is handled by the debounce action (DEMOCI-02-014), not by the concurrency block.Using a literal avoids the maintenance burden and reader confusion of an inline expression. The debounce step is the single place that encodes push-to-main throttling logic. |
|
DEMOCI-02-012 |
MUST | The docker compose down teardown step MUST specify a per-container stop grace period of no more than 30 seconds (e.g. --timeout 30). Docker's default 10-second SIGTERM window followed by unbounded SIGKILL retries is not sufficient when a vendor container wedges; without an explicit cap, compose down can consume the remaining CI job budget.Issue #2245 observed a wedged vendor container consuming the remaining CI budget during teardown because no explicit --timeout was set. A 30-second cap bounds the worst-case teardown cost per container to a predictable budget while still giving well-behaved containers time to flush state before SIGKILL. |
Refines: DEMOCI-02-010 |
DEMOCI-02-016 |
MUST | Both the push and pull_request triggers of .github/workflows/python-app.yml MUST include uv.lock in their paths filter.DEMOCI-02-004 skips the demo workflow on Dependabot PRs on the premise that python-app.yml still tests them. A Dependabot bump inside an existing version range touches only uv.lock, so without it in the filter such a bump merges with no test run at all — which is how the py-trees 2.6.0 bump reached main untested (#3610). |
Derives from: DEMOCI-02-004 |
Extensibility for Additional Demo Scenarios
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-03-001 |
SHOULD | The demo workflow SHOULD be extended to include additional demo scenarios (e.g., three-actor, multi-vendor) as each scenario reaches a stable, reproducible state. Starting with the rv scenario lets CI run reliably while more complex scenarios are still being developed. Additional scenarios add coverage without blocking CI on unstable demos. |
|
DEMOCI-03-002 |
MUST | When a new demo scenario is added to the codebase, the demo CI workflow MUST be updated in the same PR to include that scenario in the demo-integration.yml trigger. | |
DEMOCI-03-003 |
SHOULD | Each demo scenario added to CI SHOULD be run as a separate job (or matrix entry) so that failures are independently reported and one scenario failure does not prevent other scenarios from running. | |
DEMOCI-03-004 |
MUST | Each demo scenario (including RVV and any future scenario) MUST be added to demo-integration.yml as a separate parallel job in the same PR that introduces the scenario script; the job MUST use --abort-on-container-exit --exit-code-from demo-runner, timeout-minutes: 15, and the same teardown and log-upload steps as the rv job (DEMOCI-02-006, DEMOCI-02-009, DEMOCI-02-010)Running scenarios in series would make CI proportionally slower with each scenario added. Parallel jobs keep wall-clock time bounded and produce independent pass/fail signals per scenario. Requiring the CI job in the same PR as the scenario script ensures no scenario ships without CI coverage. |
Refines: DEMOCI-03-003 Refines: DEMOCI-03-002 |
DEMOCI-03-005 |
MUST | Each matrix entry in the demo CI workflow MUST declare an explicit test_file field naming the scenario-specific invariant test file (e.g. test/ci/invariants/test_rv_invariants.py). The case-ledger invariant step MUST invoke pytest with that file path directly rather than using the -m case_ledger_invariants marker alone.Using the marker alone relies on each test file skipping when its devlogs directory is absent — an implicit, fragile coupling between the filesystem state and test selection. An explicit file path makes the scenario-to-test relationship declarative and visible in each generated .github/demo-scenarios.json entry (DEMOCI-11-004), whose test_file is derived from the registered scenario name (DEMOCI-11-003). |
Refines: DEMOCI-03-004 |
Invariant Harness as an Independent CI Gate
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-04-001 |
MUST | The case-ledger invariant harness for each demo scenario MUST run as a separate CI job (distinct from the demo-runner job), so that invariant failures produce an independent pass/fail status on the pull request that is visible even when the demo job also fails. When both the demo run and the invariant harness are co-located in the same CI job, a primary demo failure draws reviewer attention away from invariant failures. In PR #1590, a missing notes-exchange phase failed silently across multiple fix cycles because the invariant failure was masked by the demo failure in the same job output. |
Refines: DEMOCI-03-003 |
DEMOCI-04-002 |
MUST | The invariant-harness job MUST declare a needs: dependency on the demo job for the same scenario matrix entry, and MUST use if: always() so it runs regardless of whether the demo job passed or failed. This applies to the demo pass and fail outcomes only; a cancelled demo job is a distinct third outcome for which the harness MUST be skipped (DEMOCI-04-007).The purpose of the separate invariant job is to provide a signal even when the demo itself fails. Using if: always() and needs: on the demo job ensures the invariant job runs after artifacts are uploaded and never skips due to a demo failure. A cancelled demo job uploads no artifact, so running the harness against it would only produce a misleading download-artifact failure (DEMOCI-04-007, #3249) — hence the explicit cancellation carve-out. |
Refines: DEMOCI-04-001 |
DEMOCI-04-003 |
MUST | The invariant-harness job MUST download the scenario's case-log JSONL artifact produced by the demo job before running pytest, so that the harness reads the actual output of the demo run that completed in that workflow execution. | Refines: DEMOCI-04-001 |
DEMOCI-04-004 |
MUST | Each demo scenario's invariant test file (e.g. test/ci/invariants/test_rv_invariants.py) MUST include a per-scenario expected-semantic-types list that is comprehensive for that scenario: it MUST cover all protocol phases that the scenario is designed to exercise, not only the universal phases (DEMOMA-16-001) shared by all scenarios.A shared minimal list (validate_report, add_participant_status_to_participant, close_case, add_note_to_case, engage_case) does not guard against a scenario-specific phase (e.g. notes-exchange) regressing silently. Each scenario's expected-semantic-types list must reflect what that scenario is designed to prove end-to-end. |
Refines: DEMOCI-03-005 |
DEMOCI-04-005 |
MUST | The invariant-harness job MUST NOT run for Dependabot PRs, matching the demo job's exclusion (DEMOCI-02-004). *Without the guard, a Dependabot PR skips the demo job (correct) but triggers the invariant-harness job, which immediately fails because actions/download-artifact finds no artifact — producing spurious failing status checks on every Dependabot PR. |
|
This entry originally mandated the literal !startsWith(github.head_ref, 'dependabot/') expression in this job's own if:. That prescribed a mechanism rather than the outcome. Once scenario selection moved into a scenarios job (DEMOCI-06-004), duplicating the head_ref test in three places invited drift, so the exclusion is now enforced once upstream and inherited via needs.scenarios.result. The requirement is the outcome — no invariant-harness run on Dependabot PRs — not the specific predicate. Note that always() alone is insufficient: it runs the job even when an upstream job was skipped, which is exactly the spurious-failure case this entry exists to prevent.* |
Refines: DEMOCI-04-002 Refines: DEMOCI-02-004 |
||
DEMOCI-04-006 |
MUST | The Dependabot exclusion guard for the invariant-harness job MUST be enforced such that a bare always() condition cannot resurrect the job when an upstream job was skipped.always() alone runs the job even when an upstream job was skipped, which is exactly the spurious-failure case when Dependabot PRs skip the demo job but trigger the invariant-harness job. |
|
DEMOCI-04-007 |
MUST | Because the Dependabot exclusion is applied once on the upstream scenarios job (DEMOCI-06-004), the invariant-harness if: MUST pair always() with a check that the scenarios job succeeded AND a check that the demo job was not cancelled, i.e. always() && needs.scenarios.result == 'success' && needs.demo.result != 'cancelled'.*Pairing always() with the upstream result check ensures the invariant-harness job runs after artifacts are uploaded and never runs spuriously when the scenarios job was skipped. |
|
The needs.demo.result != 'cancelled' clause closes a distinct spurious-failure path (#3249). When a push-to-main run is cancelled by the concurrency group (DEMOCI-02-013), the demo job is cancelled before it uploads any case-log artifact, so the invariant-harness download-artifact step (DEMOCI-04-003) hard-fails with "Artifact not found". That turns a cancellation into a job failure, which the notify step (CISEC-05-001) then files as a spurious ci:main-failure issue. Skipping the harness on a cancelled demo job keeps the run free of a failure result, so notify correctly defers to the superseding run. On the pure-cancellation path needs.demo.result is cancelled, so the harness is skipped; the success and genuine-failure paths (DEMOCI-04-002) are unaffected. In the rare mixed case — a demo leg genuinely fails and a newer push then cancels the remaining fail-fast: false legs — GitHub's aggregate needs.demo.result precedence between failure and cancelled is undocumented; should it resolve to failure, the harness runs and notify files, which is correct because a real failure did occur.* |
Refines: DEMOCI-04-002 Refines: DEMOCI-02-013 |
Post-Merge Validation on the Default Branch
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-05-001 |
MUST | The demo workflow MUST run the full demo + invariant-harness matrix against the main branch HEAD on push events to main (path-filtered per DEMOCI-02-003), producing a commit status on the merged result. The pull_request trigger validates only the ephemeral merge commit (PR head merged onto main at test time), not the actual merged result of PRs that land concurrently. Two PRs each green in isolation can interact to break main, and without an on-main run nothing re-validates the demos or invariants against the real merged main HEAD. The path-filtered pull_request trigger only re-checks the demos when a later PR happens to touch a filtered path, so a broken main can persist untested until then. A push-to-main run gives the default branch its own per-commit demo/invariant baseline. |
Refines: DEMOCI-02-002 |
DEMOCI-05-002 |
MUST | Main-branch demo runs MUST produce a pass/fail signal for each burst of merges. A burst is defined as a sequence of merges separated by less than the debounce window (60 seconds, per DEMOCI-02-014); only the last commit in a burst runs to completion. Per-commit granularity on main is not required: a time-range bisect across a typical 4–6 PR batch is sufficient to identify regressions. | Refines: DEMOCI-05-001 Refines: DEMOCI-02-011 |
DEMOCI-05-004 |
MUST | The push-to-main trigger MUST carry the same Dependabot exclusion semantics as the pull_request trigger where applicable. Because push events to main do not originate from dependabot/ head refs, the guard need not be duplicated on the push path, but the workflow MUST NOT gate the on-main run behind github.head_ref-based conditions (which are empty on push events) in a way that would skip the on-main run entirely.The existing !startsWith(github.head_ref, 'dependabot/') guards (DEMOCI-02-004, DEMOCI-04-005) evaluate against the PR head ref, which is empty on push events. A naive reuse of that condition as a job-level if: would still pass on push (empty string does not start with 'dependabot/'), but authors MUST verify the on-main run is not inadvertently skipped when consolidating the guard expressions. |
Refines: DEMOCI-05-001 |
Demo Scenario Coverage Model — PR Validation Set vs. Full Post-Merge Set
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-06-001 |
MUST | A scenario coverage analysis MUST be performed that maps each of the demo scenarios to the distinct Vultron protocol phases and message-type milestones it exercises, producing a coverage matrix. The scenarios share common helper phases but each exercises a distinct set of protocol phases (e.g. invite_actor_to_case, offer_case_participant, accept_invite_actor_to_case). Milestone sets were often copied from simpler scenarios and may under-report what a scenario actually exercises. A complete per-scenario coverage matrix is prerequisite to designing a minimum PR validation set. |
Implements: DEMOCI-03-001 Refines: DEMOCI-04-004 |
DEMOCI-06-005 |
MUST | The scenario coverage analysis MUST inspect the actual milestone sets defined in each scenario script rather than assuming they are complete, and MAY update scenario milestone sets where copy-paste from simpler scenarios has left them incomplete. Milestone sets were often copied from simpler scenarios and may under-report what a scenario actually exercises; inspecting the scripts directly is necessary to produce an accurate coverage matrix. |
|
DEMOCI-06-006 |
MUST | The validated coverage matrix MUST be recorded in notes/demo-ci-scenario-coverage.md.Recording the matrix in a notes file puts the coverage analysis where scenario authors will read it. Since ADR-0098 the matrix is a checked copy rather than an authority: the normative statement of the minimum PR validation set is DEMOCI-06-002, and the scenario set itself is declared by the registry (DEMOCI-11-001). |
|
DEMOCI-06-002 |
MUST | The minimum PR validation scenario set is rv, rvcv-handoff, rcvcv, rcv-reject, rcv-embargo, and rcvv-embargo. Together these scenarios cover all distinct protocol semantic types exercised by the full scenario suite: the universal types (DEMOMA-16-001) plus invite_actor_to_case, offer_case_participant, accept_invite_actor_to_case, accept_actor_recommendation, accept_case_ownership_transfer, reject_invite_actor_to_case, invite_to_embargo_on_case, accept_invite_to_embargo_on_case, activate_embargo_on_case, and remove_embargo_event_from_case. The demo-integration.yml workflow MUST use this scenario set as the pull_request matrix and run only these scenarios on every PR commit.Running all scenarios on every PR commit incurs a fan-in barrier proportional to the slowest scenario. The minimum set preserves full protocol-phase coverage while reducing PR wall-clock cost. rv covers the 2-actor baseline; RVCV-handoff adds invite + accept + ownership-transfer; RCVCV adds offer_case_participant, accept_actor_recommendation, and ≥3-actor invite/accept chains; RCV-reject adds reject_invite_actor_to_case (the only scenario that exercises a vendor rejecting an invitation after entering RM.RECEIVED). RCV-embargo and RCVV-embargo are the only sources of the embargo semantic types, by different paths. Rationale recorded in notes/demo-ci-scenario-coverage.md. See ADR-0052. |
Refines: DEMOCI-06-001 |
DEMOCI-06-003 |
MUST | The full scenario suite MUST run on push events to main, regardless of which subset runs for PR validation, so that all scenario variants continue to generate case-log invariant signals on the default branch. The demo-integration.yml workflow MUST include a push: branches: ["main"] trigger that runs all scenarios (rv, rvv, rvcv-extension, rvcv-handoff, rccv-extension, rccv-handoff, rcvcv, rcv, rcv-reject, rcv-embargo, rcvv-embargo).The post-merge run is the safety net for cross-PR regressions; it must cover all scenarios to ensure no scenario silently degrades after its covered phases are removed from the PR validation set. |
Refines: DEMOCI-06-002 Refines: DEMOCI-05-001 |
DEMOCI-06-004 |
MUST | Per-event scenario selection (DEMOCI-06-002, DEMOCI-06-003) MUST be implemented by resolving the matrix before matrix expansion, not by a job-level if: condition that references the matrix context.*GitHub evaluates job-level if: conditions BEFORE expanding the matrix, so the matrix context does not exist at that point. Referencing it is valid YAML — a YAML parser and yaml.safe_load both accept the file — but GitHub rejects the entire workflow with a startup failure: zero jobs scheduled, no logs, no annotations, no per-job status, and a run whose name is reported as the file path instead of the workflow name. |
|
| This failure mode is near-silent. It broke demo-integration.yml for 116 consecutive runs (2026-08-06 through 2026-08-07) across every branch, including main. Because the same commit added the push-to-main trigger, every push registered a startup failure before the branch filter could be evaluated, so the red X appeared even on branches the workflow was never meant to run on — which made the signal read as noise rather than a real regression. | |||
Deriving both matrices from one JSON file also removes the verbatim matrix duplication between the demo and invariant-harness jobs, where the two copies could silently drift apart. |
|||
Detection is delegated to actionlint in CI and pre-commit (DEMOCI-08-001), which flags the illegal context reference directly.* |
Refines: DEMOCI-06-002 Refines: DEMOCI-06-003 |
||
DEMOCI-06-007 |
MUST | CI MUST resolve the scenario list from a single declarative file (.github/demo-scenarios.json), with each entry carrying a boolean full_suite_only field.Resolving both matrices from one file prevents the PR and post-merge scenario lists from drifting apart and makes the full_suite_only flag the canonical mechanism for per-event filtering. Since ADR-0098 that file is generated from the scenario registry (DEMOCI-11-004) rather than hand-maintained, and the authority for which scenarios exist rests with the demo modules (DEMOCI-11-001); this requirement governs only what CI reads the matrix from, which must remain a committed file because the scenarios job has no Python. |
|
DEMOCI-06-008 |
MUST | A scenarios job MUST filter the scenario list in .github/demo-scenarios.json according to github.event_name and expose the result as a JSON job output.Centralising event-based filtering in a dedicated job ensures both downstream jobs consume the same filtered list and removes per-job if: conditions that would reference the matrix context. |
|
DEMOCI-06-009 |
MUST | The demo and invariant-harness jobs MUST derive their matrices via fromJSON() of the scenarios job output.Using fromJSON of a shared upstream output guarantees both jobs expand the same scenario set and eliminates verbatim matrix duplication that could silently drift. |
|
DEMOCI-06-010 |
MUST | A job-level if: condition referencing matrix.* MUST NOT be used to gate matrix entries.GitHub evaluates job-level if: conditions before expanding the matrix, so the matrix context is undefined at that point; referencing it causes a startup failure with no logs or per-job statuses. |
Embargo Lifecycle Scenario CI Registration (rcv-embargo, rcvv-embargo)
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-07-001 |
MUST | The rcv-embargo scenario MUST be registered with in_pr_set=True (DEMOCI-11-001), so that its generated entry in .github/demo-scenarios.json (DEMOCI-11-004) carries "full_suite_only": false. The scenario runs on the reporter (reporter role), coordinator, vendor, and case-actor services.Registering both embargo scenarios in the PR set places them in PR validation immediately, per the add-now/remove-later policy from issue #1222 planning. The registry is the authority; the JSON file is its generated copy. CI log directories use scenario-role names ( reporter for the reporter service) so invariant harnesses can load devlogs by role rather than docker service name. |
Refines: DEMOCI-04-001 Refines: DEMOMA-20-005 |
DEMOCI-07-004 |
MUST | The test_file matrix entry for the rcv-embargo CI job MUST point to test/ci/invariants/test_rcv_embargo_invariants.py.An explicit file path makes the scenario-to-test relationship declarative per DEMOCI-03-005. The path is derived from the registered name by convention (DEMOCI-11-003) and written into the generated .github/demo-scenarios.json entry (DEMOCI-11-004). |
|
DEMOCI-07-005 |
MUST | The devlogs directory for the rcv-embargo CI run MUST use scenario-role names: reporter, coordinator, vendor, case-actor.Scenario-role names in devlogs match scenario code and make log archaeology easier and consistent with the post-DEMOMA-12 naming convention. |
|
DEMOCI-07-002 |
MUST | The rcvv-embargo scenario MUST be registered with in_pr_set=True (DEMOCI-11-001), so that its generated entry in .github/demo-scenarios.json (DEMOCI-11-004) carries "full_suite_only": false. The scenario runs on the reporter (reporter role), coordinator, vendor, actor5 (seeded as vendor2), and case-actor services.The rcvv-embargo scenario needs a fourth actor beyond the three used by rcv-embargo; Vendor2 runs in the actor5 slot of the multi-actor compose file, which CI starts as a whole. Scenario-role names in devlogs (vendor2, not actor5) match scenario code, making log archaeology easier and consistent with the RCVCV pattern (DEMOMA-19-007). |
Refines: DEMOCI-04-001 Refines: DEMOMA-21-006 |
DEMOCI-07-006 |
MUST | The test_file matrix entry for the rcvv-embargo CI job MUST point to test/ci/invariants/test_rcvv_embargo_invariants.py.An explicit file path makes the scenario-to-test relationship declarative per DEMOCI-03-005. The path is derived from the registered name by convention (DEMOCI-11-003) and written into the generated .github/demo-scenarios.json entry (DEMOCI-11-004). |
|
DEMOCI-07-007 |
MUST | The devlogs directory for the rcvv-embargo CI run MUST use scenario-role names: reporter, coordinator, vendor, vendor2, case-actor.Scenario-role names in devlogs match scenario code and make log archaeology easier and consistent with the RCVCV pattern (DEMOMA-19-007). |
|
DEMOCI-07-003 |
SHOULD | A follow-up coverage analysis SHOULD be performed after rcv-embargo and rcvv-embargo are implemented and passing in CI, to evaluate whether the minimum PR set defined in DEMOCI-06-002 requires expansion to preserve full embargo lifecycle protocol coverage.The embargo semantic types ( invite_to_embargo_on_case, accept_invite_to_embargo_on_case, activate_embargo_on_case, remove_embargo_event_from_case) are produced only by rcv-embargo and rcvv-embargo, so the minimum PR set preserves embargo coverage only while at least one of them is a member. The coverage matrix in notes/demo-ci-scenario-coverage.md is where that membership is checked against the harness semantic-type constants. |
Refines: DEMOCI-06-001 Refines: DEMOCI-06-002 Refines: DEMOCI-06-003 |
DEMOCI-07-008 |
SHOULD | When the follow-up coverage analysis is performed, it MUST update notes/demo-ci-scenario-coverage.md, DEMOCI-06-002, and DEMOCI-06-003 to reflect its conclusions.The analysis findings must be propagated to all three documents so that the coverage matrix, minimum PR set, and full scenario suite list remain mutually consistent. |
|
DEMOCI-07-009 |
SHOULD | If the embargo scenarios introduce protocol semantic types not covered by rv, rvcv-handoff, or rcvcv, the minimum PR set defined in DEMOCI-06-002 MUST be expanded.No scenario other than rcv-embargo and rcvv-embargo commits an embargo semantic type, so a minimum PR set without them leaves the embargo lifecycle unexercised on PRs. |
|
DEMOCI-07-010 |
SHOULD | DEMOCI-06-003 MUST be updated to include rcv-embargo and rcvv-embargo in the full scenario suite list when they are wired into CI.DEMOCI-06-003 names the full scenario suite explicitly; once the embargo scenarios are wired in, the list must be updated so the post-merge validation run is complete. |
Workflow Static Analysis (actionlint)
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-08-001 |
MUST | GitHub Actions workflow files MUST be statically analysed by actionlint in CI, via a dedicated workflow (.github/workflows/actions-lint.yml) whose path filter is scoped to .github/**.*A workflow can be syntactically valid YAML yet be rejected by GitHub at startup — the matrix-context-in-job-if: bug of DEMOCI-06-004 is the motivating case. Startup failures produce no logs, no annotations, and no job-level statuses, so nothing in the repository's own test or lint suite can detect them and the only symptom is a bare red X. actionlint resolves context availability per field and reports the illegal reference as a one-line error with the exact position. |
|
| Pinning by version and checksum keeps CI reproducible and prevents a retagged or compromised upstream release from silently altering what executes in the pipeline.* | Refines: DEMOCI-06-004 | ||
DEMOCI-08-003 |
MUST | The actionlint check MUST NOT be added to python-app.yml, whose path filter is scoped to vultron/** and test/** and therefore would not trigger on a workflow-only change.A workflow-only change (touching only .github/**) would not match python-app.yml's path filter, so placing actionlint there would leave workflow changes unchecked. |
|
DEMOCI-08-004 |
MUST | actionlint MUST be registered as a pre-commit hook so that workflow errors are caught before push.A pre-commit hook catches the illegal context reference before it reaches CI, closing the feedback loop that otherwise requires a push and a CI run to surface the error. |
|
DEMOCI-08-005 |
MUST | The actionlint version used in CI MUST be pinned.Pinning by version keeps CI reproducible and prevents a retagged upstream release from silently altering what executes in the pipeline. |
|
DEMOCI-08-006 |
MUST | The CI download of actionlint MUST be verified against a pinned SHA256 checksum.A checksum verification prevents a compromised upstream release from silently altering what executes in the pipeline. |
|
DEMOCI-08-002 |
MUST | The actions-lint.yml workflow MUST verify that .github/demo-scenarios.json is a non-empty array.Once the matrix is data rather than inline YAML, malformed data degrades silently instead of failing loudly. A string "false" is truthy to jq's equality test against false and would drop a scenario from the PR set; a missing test_file key expands into a job that runs pytest ""; a duplicated demo name collides on the upload-artifact name and one scenario's logs overwrite the other's; and an empty PR set would let pull_request runs report a vacuous success with zero scenarios executed — the same false-green class of failure that DEMOCI-06-004 exists to prevent. A non-existent test_file is also caught here rather than 15 minutes into a demo run. |
Refines: DEMOCI-08-001 Refines: DEMOCI-06-004 |
DEMOCI-08-007 |
MUST | The actions-lint.yml workflow MUST verify that every entry in .github/demo-scenarios.json has a non-empty string demo field, a non-empty string test_file field, and a boolean (not string) full_suite_only field.A missing test_file key expands into a job that runs pytest ""; a string "false" is truthy to jq and would drop a scenario from the PR set. |
|
DEMOCI-08-008 |
MUST | The actions-lint.yml workflow MUST verify that no demo name is duplicated in .github/demo-scenarios.json.A duplicated demo name collides on the upload-artifact name and one scenario's logs overwrite the other's. |
|
DEMOCI-08-009 |
MUST | The actions-lint.yml workflow MUST verify that at least one entry in .github/demo-scenarios.json has full_suite_only: false.An empty PR validation set would let pull_request runs report a vacuous success with zero scenarios executed — the same false-green class of failure that DEMOCI-06-004 exists to prevent. |
|
DEMOCI-08-010 |
MUST | The actions-lint.yml workflow MUST verify that every test_file path referenced in .github/demo-scenarios.json exists in the repository.A non-existent test_file path should be caught at schema-validation time rather than 15 minutes into a demo run. |
Integration Branch Support
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-09-001 |
MUST | The workflow_dispatch trigger in demo-integration.yml MUST declare an optional ref input (type: string, default empty string) whose description explains that it accepts a branch, tag, or SHA to check out before running the full demo suite. All three actions/checkout steps in the workflow (in the scenarios, demo, and invariant-harness jobs) MUST unconditionally pass ref: ${{ inputs.ref || github.ref }}. When inputs.ref is non-empty the suite runs against that ref; when inputs.ref is empty or absent (all non-workflow_dispatch events), the expression transparently falls through to github.ref so normal pull_request and push checkouts are unaffected.When multiple related bug-fix PRs land on a shared integration branch (e.g. fix/demo-ci) rather than racing to main, the full demo suite needs to be runnable against that branch without opening a dummy PR to main. A workflow_dispatch ref input enables manual full-suite validation at any point during the integration window with a single button click in the GitHub UI. The unconditional expression form avoids a conditional if: inputs.ref != '' guard that would silently break normal checkout behaviour. |
Refines: DEMOCI-05-001 |
DEMOCI-09-002 |
MUST | When an integration branch (e.g. fix/demo-ci) has merged to main and all its child issues are closed, the branch name MUST be removed from the pull_request and push trigger branches: arrays in demo-integration.yml. Any inline comments that referenced the temporary branch as a # remove when merged note MUST also be removed at the same time.Stale branch entries in trigger arrays are dead configuration: the branch no longer exists, so the entries neither fire nor provide documentation value. Leaving them risks confusing future maintainers about which branches are legitimate targets. Removing them (together with any accompanying comments) keeps the trigger configuration self-consistent and matches the actual lifecycle of integration branches. |
Refines: DEMOCI-09-001 |
Case-Ledger Artifact Availability on Failure
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-10-001 |
MUST | Every demo scenario MUST export its case ledgers from a path that runs whether or not the scenario's phases succeeded — in practice, from the shared scenario harness's exception path as well as its success path (see DEMOMA-23-001). A scenario MUST NOT place the ledger dump only after its last phase, where any escaping exception skips it.demo_check and demo_step accumulate rather than raise, so the exceptions that do escape a scenario are the ones that most need forensics — and those were exactly the runs that uploaded nothing, because the dump sat inline after the last phase. Losing the ledgers on failure inverts the value of the artifact. |
Refines: DEMOCI-04-001 |
DEMOCI-10-002 |
MUST | The case-ledger dump MUST always write a dump-manifest.json under devlogs/<demo-name>/, even when it captured no ledgers at all. The manifest MUST record demoName, caseId, ledgerFileCount, targetCount, an optional top-level reason, and one actors[] entry per dump target carrying actorName, routeKey, captured, entryCount, ledgerFile, and reason. It MUST also record an unreplicatedActors[] list (empty when none), one {actorName, routeKey} entry per actor that acted in the case but holds no ledger replica to dump, so the invariant harness can resolve that actor's entries to a name (DEMOMA-22-005).The upload-artifact step and the invariant-harness job's download both need the artifact to exist. An always-present manifest makes the download succeed unconditionally and turns "nothing was uploaded" into a machine-readable statement of which actors were missing and why, so the invariant harness can report a real invariant result instead of a download error. The manifest's per-actor reason field also distinguishes a network-timeout dump failure from a scenario that never started: a timed-out dump records the error in the actor's reason alongside captured: false, while a pre-run CI sentinel (DEMOCI-10-006) records an empty actors array (actors: []). |
Refines: DEMOCI-10-001 |
DEMOCI-10-003 |
MUST | test/ci/invariants/common.py::load_devlogs() MUST fail — not skip — when a dump-manifest.json is present under the scenario's devlogs directory but no *-case-ledger.jsonl file exists.Skipping on absent data is right for a developer who has not run the demo and wrong for CI, where absent data means the scenario died. The manifest is the evidence that distinguishes the two cases; without it the harness reported green for scenarios that produced no ledger at all. |
Refines: DEMOCI-04-001 Implements: DEMOCI-10-002 |
DEMOCI-10-008 |
MUST | test/ci/invariants/common.py::load_devlogs() MUST fail when a dump-manifest.json is present under the scenario's devlogs directory but is unparseable.An unparseable manifest indicates data corruption during the CI run; failing loudly surfaces this as a real harness error rather than a silent skip. |
|
DEMOCI-10-009 |
MUST | test/ci/invariants/common.py::load_devlogs() MUST skip only when there is no evidence the demo ran: no devlogs/ directory, no scenario sub-directory, or neither ledger files nor a manifest.Skipping is appropriate only when a developer has not run the demo locally; the manifest distinguishes that case from a CI run that produced no artifacts. |
|
DEMOCI-10-004 |
MUST | A failure inside the case-ledger dump MUST NOT replace the exception that ended the scenario. A dump that runs on the failure path runs precisely when the system is already unhealthy, so it is the most likely thing to fail second. If it raises over the first exception, the fix that preserves the artifacts destroys the diagnosis instead — the same class of masking as the try/finally: assert_demo_success() shape it replaces. |
Refines: DEMOCI-10-001 |
DEMOCI-10-010 |
MUST | When the case-ledger dump fails, it MUST record its own failure through the accumulator and the manifest. Recording dump failures in the manifest makes them machine-readable and distinguishable from a scenario that never started. |
|
DEMOCI-10-011 |
MUST | When the case-ledger dump fails, the original scenario exception MUST propagate unchanged. Propagating the original exception unchanged ensures the root-cause diagnosis is not replaced by a secondary dump failure. |
|
DEMOCI-10-005 |
MUST | The invariant-harness pytest session MUST exit non-zero when every collected test was skipped. A pytest_sessionfinish hook registered in test/ci/invariants/conftest.py MUST set session.exitstatus = 1 whenever at least one test was reported and all reported test outcomes were "skipped".When devlogs are absent or a scenario sub-directory is missing, load_devlogs() calls pytest.skip() at fixture level and every test in the file skips. pytest exits 0 by default for a skip-only session, which lets CI mark the invariant-harness job green even though no invariant was actually checked. Forcing exit 1 converts a vacuous pass into a visible failure, closing the last gap in DEMOCI-10 coverage. |
Refines: DEMOCI-10-003 |
DEMOCI-10-006 |
MUST | The CI demo job MUST write a pre-run sentinel dump-manifest.json under devlogs/<demo-name>/ immediately before launching the demo-runner container.scenario_harness() only runs when the demo-runner container starts and the scenario module imports successfully. Docker startup failures, import errors, and health-check sys.exit(1) calls all exit before scenario_harness() is entered, leaving devlogs/<demo>/ empty. The upload-artifact step then publishes nothing, the download-artifact step fails with "Artifact not found", and the invariant-harness job reports a plumbing error instead of an invariant result. The sentinel step closes this gap: the artifact directory is non-empty regardless of how early the runner exits, so the download succeeds and load_devlogs() reports a real failure via the manifest-without-ledgers path (DEMOCI-10-003). |
Refines: DEMOCI-10-002 Refines: DEMOCI-10-003 |
DEMOCI-10-012 |
MUST | The pre-run sentinel dump-manifest.json MUST conform to the DEMOCI-10-002 schema with caseId: null, ledgerFileCount: 0, targetCount: 0, and a reason string indicating the demo-runner had not yet started.The sentinel must be schema-conformant so the invariant-harness job can parse and interpret it via the manifest-without-ledgers path (DEMOCI-10-003). |
|
DEMOCI-10-013 |
MUST | When the demo-runner reaches scenario_harness(), the harness MUST overwrite the pre-run sentinel with the real dump manifest.Overwriting the sentinel ensures that a successful run does not leave misleading pre-run metadata as the artifact for the invariant-harness job to read. |
|
DEMOCI-10-007 |
MUST | When a dump-manifest.json is present under the scenario's devlogs directory and its caseId field is non-null, test/ci/invariants/common.py::load_devlogs() MUST restrict the returned ledger entries to those whose case_id matches the manifest's caseId. It MUST NOT return entries from a different case that happen to reside in the same devlogs/ directory due to accumulated local demo runs.Without case-scoped filtering, accumulated JSONL files from multiple local demo runs that produced different case IDs are concatenated. test_invariant_1_local_hash_chain_consistent then sees entries from two chains interleaved, producing false prevLogHash mismatches. DEMOCI-10-002 requires the manifest to record caseId precisely so load_devlogs() can scope itself: a non-null caseId is the discriminator that turns "all entries in the directory" into "all entries for this run". Issue #2273. |
Refines: DEMOCI-10-002 |
DEMOCI-10-014 |
MUST | When test/ci/invariants/common.py::load_devlogs() fails because a dump-manifest.json is present but no ledger files are, its failure message MUST name the layer that failed: when every manifest records caseId: null and targetCount: 0 with a reason other than the harness's crashed-dump backstop reason (the scenario aborted before a case existed, or only the pre-run sentinel was written) the message MUST say the ledger invariants were not exercised and point at the demo-integration job for the cause; when a manifest carries the crashed-dump reason the message MUST say the dump raised and that whether a case existed is unknown; only when a manifest names a case MAY the message call the outcome a ledger invariant failure.A demo that aborted upstream still fails the harness (DEMOCI-10-003), but a headline that insists "this is a real invariant failure" over a delivery flake sends the triager to the ledger when nothing about the ledger was tested. The manifest already carries the discriminator (DEMOCI-10-002, DEMOCI-10-012); the message reads it rather than asserting a conclusion the evidence does not support (ISSUE-3879, recorded on #2898). |
Refines: DEMOCI-10-003 |
Demo Scenario Registry and Derived Artifacts
Requirements for the self-registering demo scenario registry introduced by ADR-0098. The scenario set was previously restated by hand in eight-plus places with nothing enforcing agreement, and had already drifted: a documented vc sub-command named a demo script that never existed. These requirements make the demo modules the sole declaration point and every scenario table a derived artifact.
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOCI-11-001 |
MUST | Each demo scenario module under vultron/demo/scenario/ MUST declare itself to the scenario registry at import time, and the registry MUST be the sole declaration point for the set of demo scenarios that are implemented. A scenario that is specified but not yet built MUST NOT be registered; it is declared by its spec group and the planned-scenario register instead (DEMOCI-11-010).The demo module is the only artifact that can know a demo exists. Any separate list — a data file, an import list, a documentation table — can omit a scenario, or claim one that does not exist, without failing. vultron/demo/scenario/README.md listed a vc sub-command backed by a vc_demo.py that has never existed in the repository under a heading reading "Available scenario demos"; the row was deliberate forward documentation of a specified but unbuilt scenario (DEMOMA-25, ISSUE-2591), and nothing could distinguish it from a scenario that was actually available. |
|
DEMOCI-11-002 |
MUST | Registry discovery MUST enumerate the scenario package (pkgutil-style traversal of vultron/demo/scenario/). It MUST NOT rely on a hand-maintained list of imports.Import-time registration only observes modules that were imported, so a hand-written import list reintroduces the silent omission the registry exists to remove: the new demo is written, nobody adds it to the list, and every generated table looks complete and is wrong. This is the discovery hazard already recorded for the AS2 vocabulary registry. |
Refines: DEMOCI-11-001 |
DEMOCI-11-003 |
MUST | A scenario's demo script, invariant harness, and narrative page paths MUST be derived from its registered name by convention (vultron/demo/scenario/<name_>_demo.py, test/ci/invariants/test_<name_>_invariants.py, docs/topics/scenarios/<name>.md, where <name_> replaces hyphens with underscores). The registry MUST NOT store these paths.A stored path can be wrong about itself; a derived path either resolves or fails a check. Every registered scenario satisfies all three conventions, so no exception list is required, and introducing an override field would re-open the drift channel for every scenario rather than one. |
Refines: DEMOCI-11-001 |
DEMOCI-11-004 |
MUST | .github/demo-scenarios.json MUST be generated from the scenario registry and MUST remain committed. It MUST carry only the keys the CI matrix consumes (demo, test_file, full_suite_only), and full_suite_only MUST be an explicit boolean on every entry.The scenarios job resolves the matrix with jq immediately after checkout, in a job with no Python environment, so the matrix cannot be computed at run time and the file cannot be build-time-only. Entries are splatted into matrix: include:, so every extra key becomes a matrix variable visible to every step of two jobs. The selection filter is select(.full_suite_only == false), so an omitted field silently drops a PR-set scenario from the matrix instead of erroring. |
Refines: DEMOCI-06-007 Refines: DEMOCI-06-004 |
DEMOCI-11-005 |
MUST | Every committed artifact generated from the scenario registry MUST be gated by a check mode wired into pre-commit, which fails when the committed artifact does not match the registry. A generated file that is committed but ungated is just a hand-edited file with extra steps. The repository already runs this arrangement for docs/adr/index.md via the adr-index-sync hook. |
Refines: DEMOCI-11-004 |
DEMOCI-11-006 |
MUST NOT | Documentation outside the MkDocs tree MUST NOT use {% include-markdown %} to source a scenario table. Such a table MUST instead be generated into the file, or — where it interleaves hand-written columns the registry does not hold — checked in place against the registry (DEMOCI-11-007).The include-markdown plugin expands only during a MkDocs build, and mkdocs.yml references neither notes/ nor test/ci/. An include directive in either renders as literal text to every reader on GitHub and to every agent reading the raw file. Build-time rendering is the stronger guarantee — no table is committed, so drift is impossible — but it is available only inside the MkDocs tree. |
|
DEMOCI-11-007 |
MUST | Hand-written prose that enumerates scenarios — including DEMOCI-06-002, DEMOCI-06-003, the per-scenario requirements of the DEMOMA-16 group, the hand-written columns of the notes/ scenario tables, and the mkdocs.yml nav — MUST be checked for agreement with the scenario registry rather than rewritten to defer to it.A requirement that defers its content to code is a weak requirement, and the per-scenario DEMOMA-16 requirements exist so that each scenario has a citable spec ID. Generate what is derivable and check what is prose — the split vultron/metadata/adr/index_gen.py already makes when it generates the ADR index but only checks the nav, whose labels are hand-written. The per-scenario DEMOMA-16 requirements are not a contiguous ID range and MUST NOT be selected as one: DEMOMA-16-008 is the spec-to-test sync rule, DEMOMA-16-012 and DEMOMA-16-013 are RCVCV event-count requirements, and DEMOMA-16-014 and DEMOMA-16-015 are per-scenario requirements for scenarios that are specified but not yet built. |
Refines: DEMOCI-11-001 |
DEMOCI-11-008 |
MUST NOT | A consumer of a scenario table MUST NOT restate the number of demo scenarios in prose. The table is the count.test/ci/README-case-log-ratchet.md stated "nine" three times in sentences surrounding a nine-row table, so adding a scenario meant four coordinated edits to one page. This is MS-16-001 applied to the scenario set. Other consumers still carry the same restatement and must be cleaned up when the check lands: docs/topics/scenarios/index.md, the coverage table in notes/demo-ci-scenario-coverage.md, and the header comment of .github/workflows/demo-integration.yml, which states a count for both the PR set and the full set. |
Refines: DEMOCI-11-007 |
DEMOCI-11-009 |
MUST | docs/topics/scenarios/index.md MUST render its scenario table at build time from the registry. No scenario table may be committed in that page.The page sits inside the MkDocs tree, so markdown-exec can call the dumper during the build. That is a stronger guarantee than generation plus a --check hook (DEMOCI-11-005): with no committed table there is nothing to go stale, so drift is not detected but impossible. The weaker arrangement is used elsewhere only because CI and raw-file readers cannot run a MkDocs build. Stating this as a requirement is what makes the zero-drift claim ADR-0098 records for this page true rather than aspirational. |
Refines: DEMOCI-11-001 |
DEMOCI-11-010 |
MUST | Every demo scenario that has a spec group MUST appear in exactly one of two places: the scenario registry, if it is implemented, or the planned-scenario register in notes/demo-future-ideas.md, if it is specified but not yet built. A planned entry MUST carry its tracking issue and its spec IDs. Neither membership may be inferred from the other, and a scenario MUST NOT be absent from both or present in both.Specifying a scenario before building it is normal and necessary — the spec group is how the scenario's expected behaviour is agreed before anyone writes the demo. What must not happen is the state that produced the vc row: a scenario specified (DEMOMA-25) and tracked (ISSUE-2591) but visible nowhere except a table of available demos, with nothing able to tell the two conditions apart. Registry membership is therefore defined to mean "built", the planned register carries everything specified but unbuilt, and the partition between them is checked. This also makes the reverse direction checkable: DEMOMA-16-014 and DEMOMA-16-015 describe scenarios that are correctly absent from the registry, so a one-directional registry-to-prose check would report agreement while silently ignoring them. |
Refines: DEMOCI-11-001 Refines: DEMOCI-11-007 |
DEMOCI-11-011 |
MUST | The demo CLI (vultron/demo/cli.py) MUST generate its per-scenario sub-commands from the scenario registry: it MUST add exactly one sub-command per registered scenario, iterating the registry rather than declaring a @main.command block per scenario. A scenario sub-command MUST NOT be hand-declared, and the CLI MUST NOT be a place a scenario can be omitted from or added to independently of the registry.ADR-0098 made the registry the sole declaration point for the CI matrix, the scenario tables and the narrative index, but the CLI was left hand-wiring one ~80-line @main.command block per scenario — the last hand-maintained copy of the scenario inventory (CONCERN-3465). A registered scenario could still have no sub-command, which is a scenario nobody can invoke; DEMOCI-11-001's set-equality intent is only complete once the CLI is a registry consumer like every other reader. The per-scenario container-URL options are genuinely per-scenario and are NOT carried by the registry decorator (DEMOCI-11-001 forbids non-derivable data there); they are declared on each scenario module as a role Type Object and consumed by a single command factory, following the ActorSession consolidation model (DEMOMA-26). The role declaration MUST agree with the scenario's main() signature, enforced by a ratchet so the two cannot drift. |
Refines: DEMOCI-11-001 Refines: DC-01-002 |
DEMOCI-11-012 |
MUST | A scenario narrative page (docs/topics/scenarios/<name>.md, and the index docs/topics/scenarios/index.md) MUST NOT declare a status or maturity frontmatter key. Every markdown page under docs/topics/scenarios/ other than the index MUST be a registered scenario's narrative page as derived by DEMOCI-11-003; a page whose stem names no registered scenario MUST fail the check.All ten pages declared status: stable, an ungated template default, while two of their scenarios were failing (ISSUE-3555). A page cannot know whether its scenario passes (MS-16-002); the live answer is the demo-integration workflow, which runs every registered scenario (DEMOCI-06-003), so an unregistered scenario's page describes what no CI run exercises. maturity is refused too (ADR-0106). Prose claiming an outcome is wrong for the same reason but is a review matter. |
Refines: DEMOCI-11-003 Refines: DEMOCI-11-007 Derives from: MS-16-002 |
DEMOCI-11-013 |
MUST | Every branch point of the demo scenario tree MUST be defined exactly once, in a branch-point table in the scenario package, with the role that decides there, the presenter prompt, and at least two choices. Each choice MUST carry a presenter label and exactly one trigger, called by the branch point's role: that role's next trigger on the path. Steps by other roles MAY run between the decision and that trigger. A choice MUST NOT be a non-trigger action (a wait, a skip or a fall-through). The prompt and choice labels MUST NOT be declared anywhere else, including on @scenario and in ui/.Live mode offers each choice as one trigger POST, and offline mode must offer the same choices (ADR-0104 amendment of 2026-10-15), so a choice that is not a trigger has no live form. A decision not to act is therefore authored as the role's next trigger on that path ("report the fix ready" instead of "invite nobody"), and a silent party shows as what the other party does next. A presenter takes over a role at a branch point and the scenario plays the other parties until the next one, so their steps may fall between a decision and its trigger. Labels in one table cannot drift between the two modes. Planning of #4726 settled these. |
Refines: DEMOCI-11-001 |
DEMOCI-11-014 |
MUST | Each registered scenario MUST declare its full path on @scenario: the ordered sequence of (branch point, choice) pairs from the tree's root to its ending, each naming an entry of the branch-point table (DEMOCI-11-013). A branch point MAY occur more than once on one path. Discovery MUST fail when a path names an unknown branch point or choice, when two scenarios declare the same path, or when one scenario's path is a strict prefix of another's, so that every registered scenario is exactly one ending of the tree.Sibling scenarios ( rv and rvv) are neither a prefix of the other, so a parent-scenario pointer is lopsided; a full path treats siblings evenly, and the tree is the merge of the paths. Tree nodes are path prefixes, not branch-point identities, because a decision can recur (#2050 invalidates and later validates). A path that is a prefix of another would make one scenario both an interior node and an ending. |
Refines: DEMOCI-11-001 Depends on: DEMOCI-11-013 |
DEMOCI-11-015 |
SHOULD | A scenario's name SHOULD keep the letter grammar of the existing names: the leading letters spell, in order, the roles that join the case (r Reporter, v Vendor, c Coordinator, d Deployer, and one new letter for each further role a scenario adds), and an optional hyphenated suffix names the outcome. A Vendor that reports its own vul joins as a Vendor, so its name begins with v. Names SHOULD NOT be generated from the path's choices.A name is the stem of the module, the invariant harness, the narrative page, the scenario_start marker and the CI matrix entry, so a rename ripples through all of them. A name generated from choices renames every scenario below a newly inserted branch point. The grammar is a naming convention kept in review, not a registry check: some joins happen in scripted steps no choice records, so checking a name needs the recording, and planning of #4726 judged that machinery not worth its upkeep for demo names. The Reporter letter f is retired with the role (ADR-0078). |
Depends on: DEMOCI-11-014 |
DEMOCI-11-016 |
MUST | The scenario-tree index the demo UI reads MUST be generated from the branch-point table and the registered paths, committed under ui/src/data/, and drift-checked per DEMOCI-11-005. It MUST carry the branch-point table, the tree as nested nodes from the root, and one entry per scenario with its label, its path and its recording files. Recording files MUST be keyed by both actor and case.The UI holds no hand-kept copy of a protocol fact (ADR-0128's rule, ADR-0104). Planned scenarios split, merge and consolidate cases (#4243, #4244, #3671), so one actor's recording may span several cases; keying by actor alone would not fit them. |
Refines: DEMOCI-11-005 Depends on: DEMOCI-11-014 |
DEMOCI-11-017 |
MUST | For every two registered scenarios whose paths share a prefix, a check MUST show that their recorded ledgers are isomorphic up to the branch point where the paths part: after each recording's ids are relabeled in order of first appearance, the entries MUST be equal, including every reference between objects, with timestamps excluded. Byte identity cannot hold for scenarios that are still copies: siblings seed different actor names, container slots and ports, so their ids differ even when the logic matches. Isomorphism checks the logic and the relationships without the literal addresses, much as position-independent code is compared, and still fails a declaration that misstates where two scenarios diverge. |
Depends on: DEMOCI-11-014 |
DEMOCI-11-018 |
MUST | Once a family of scenarios is written as composition, the recordings of every two scenarios in it whose paths share a prefix MUST be byte-identical up to the branch point where the paths part. Composition makes the shared prefix one copy of the code, so byte identity holds by construction and is the stricter check that DEMOCI-11-017's isomorphism stands in for until then. |
Refines: DEMOCI-11-017 |
Demo CLI
Requirements for a unified demo command-line interface that aggregates all individual Vultron demo scripts into a single invokable entry point. Covers CLI structure, demo isolation, Docker packaging, and test requirements.
File ID: DC
CLI Interface
Demo Utilities
| ID | Priority | Requirement | Related |
|---|---|---|---|
DC-02-001 |
MUST | Shared demo utilities (demo_step, demo_check context managers and HTTP client helpers) MUST be extracted to vultron/demo/utils.py |
|
DC-02-002 |
MUST | Demo scripts MUST be relocated to vultron/demo/ and MUST be importable as vultron.demo.<script_name> |
|
DC-02-003 |
MUST | demo_step and demo_check MUST NOT be used interchangeably. demo_step is an action step: it performs a side-effecting operation and swallows any exception into the demo failure accumulator so the scenario continues. demo_check is an observation-only assertion block: it verifies that state meets expectations and accumulates assertion failures, but MUST NOT perform any side-effecting operation.Using demo_step for observation or demo_check for actions obscures the semantic intent of the scenario. An action disguised as a check will not be correctly identified as a side-effecting step in scenario tracing and logging; an observation disguised as a step will mislead readers about what the scenario is doing vs. verifying. |
Refines: DC-02-001 |
Demo Isolation
Docker Packaging
Unit Testing
Integration Testing
Demo Scenario Report Tool
Requirements for a read-only command-line tool that parses the JSONL case-ledger replica files produced by demo scenario runs and renders a selective, human-readable report of who did what, with what activity, and in what order. Covers input discovery, the distilled intermediate data model, markdown and HTML rendering, the per-actor replica-presence matrix, friendly naming, and test requirements.
File ID: DRPT
Tool Structure and Invocation
| ID | Priority | Requirement | Related |
|---|---|---|---|
DRPT-01-001 |
MUST | The report tool MUST be implemented as a standalone module at vultron/demo/report.py, importable as vultron.demo.report, and MUST provide an if __name__ == "__main__" entry point so it is directly invokable (python -m vultron.demo.report ...).A standalone module keeps the read-only reporting utility decoupled from the demo-run CLI (DC-01) while remaining colocated with the demo code that produces the ledger files. |
|
DRPT-01-002 |
MUST | The tool MUST accept a directory path (defaulting to the DEVLOGS_DIR environment variable, or devlogs/ relative to the repository root when unset) and MUST discover input files by recursively globbing **/*-case-ledger.jsonl beneath it.This mirrors the layout written by rv_demo._phase_dump_case_ledgers ({DEVLOGS_DIR}/{demo_name}/{actor_name}/{case_id_slug}-case-ledger.jsonl) and read by the invariant harnesses in test/ci/invariants/. |
|
DRPT-01-003 |
MUST | The tool MUST derive each entry's actor name from the name of the directory that contains its JSONL file (e.g. finder, vendor, case-actor). |
|
DRPT-01-004 |
MUST | The tool MUST exit with status code 0 on success and a non-zero status code when the input directory is missing, contains no matching JSONL files, or a file cannot be parsed. | |
DRPT-01-005 |
SHOULD | The tool SHOULD also be exposed as a vultron-demo-report console-script entry point in pyproject.toml ([project.scripts]), resolving to vultron.demo.report:main. Its main MUST be invocable with no arguments (defaulting to sys.argv) so the console-script wrapper works, and MUST return the process exit code.A console script matches the convention of the other demo entry points ( vultron-demo, vultrabot_*) and makes the tool directly runnable after install without the python -m prefix. Requiring a zero-argument main guards against the latent console-script breakage seen for other demo entry points whose main required a parsed-args positional. |
Extends: DRPT-01-001 |
Distilled Timeline Model
| ID | Priority | Requirement | Related |
|---|---|---|---|
DRPT-02-001 |
MUST | The tool MUST define a first-class pydantic.BaseModel (v2) distilled event type (e.g. CaseTimelineEvent) that represents one canonical timeline event in friendly-readable form. Both the markdown and HTML renderers MUST consume this model rather than raw JSONL dicts.A typed intermediate model is the reusable, testable "friendly data" layer described in IDEA-1286; it isolates JSONL parsing quirks from rendering. |
|
DRPT-02-002 |
MUST | Each distilled event MUST surface, where available in the source entries: the acting actor, the semantic type (or extracted message semantics), the target object reference, the timestamp (receivedAt), the resulting RM/EM/CS state transition(s) from the payload snapshot, the log_index, and a short (truncated) entry_hash for chain traceability. |
|
DRPT-02-003 |
MUST | The parser MUST tolerate both camelCase and snake_case field spellings in source entries (e.g. logIndex/log_index, entryHash/entry_hash, payloadSnapshot/payload_snapshot), consistent with the tolerance already present in test/ci/invariants/common.py. |
|
DRPT-02-004 |
MUST | The tool MUST merge entries from all discovered actors into a single canonical timeline, de-duplicating replicated entries by entry_hash, and MUST order the timeline by log_index ascending.Replicas hold copies of the same canonical entries; de-duplication yields the single case story rather than N near-identical per-actor logs. |
|
DRPT-02-005 |
MUST | For each canonical timeline event, the tool MUST compute a per-actor replica-presence indicator recording which discovered actors' replicas contain that entry (by entry_hash), so replica convergence and divergence are visible at a glance. |
|
DRPT-02-006 |
MUST | When the discovered entries span more than one case (distinct case_id values — as when the default devlogs/ root accumulates multiple demo runs), the tool MUST partition the timeline by case_id.log_index restarts at 0 for each case and same-named actor directories recur across demo runs, so a single merged timeline over multiple cases interleaves unrelated events and conflates replica presence across cases. DRPT-02-004's "single canonical timeline" is well-defined only within one case; partitioning by case_id preserves that guarantee when the input directory holds more than one. |
Refines: DRPT-02-004 |
DRPT-02-007 |
MUST | build_timeline MUST skip entries whose disposition field is not "recorded" (e.g. "rejected"). Rejected entries are local-only correlation markers with empty payloadSnapshot values; including them in the report produces — actor labels and empty target columns that violate the CLP spec and mislead readers.The AGENTS.md pitfall "disposition=rejected for Local-Only Correlation Markers" documents that rejected entries must not appear in output intended for human consumption. |
Refines: DRPT-02-004 |
DRPT-02-009 |
MUST | When the discovered entries span more than one case, the tool MUST NOT interleave events from different cases. Interleaving events from distinct cases produces a meaningless merged timeline because log_index is case-local and replica presence is case-specific. |
|
DRPT-02-010 |
MUST | The distilled event model MUST carry the case_id field.Downstream partitioning and per-case rendering require each event to identify which case it belongs to. |
|
DRPT-02-011 |
MUST | Timeline ordering MUST be case-contiguous: events MUST be grouped by case_id and ordered by log_index ascending within each case group.Case-contiguous ordering preserves the causal sequence within each case while preventing cross-case log_index comparisons. |
|
DRPT-02-012 |
MUST | The replica-presence matrix MUST be computed per case. Replica presence is meaningful only within a single case; computing it across cases conflates unrelated actor participation. |
|
DRPT-02-013 |
MUST | Each renderer MUST present one clearly labelled section (or table) per case. Separate per-case sections make case boundaries visible to the reader and prevent misinterpretation of per-case metadata such as time range and replica presence. |
Friendly Naming
| ID | Priority | Requirement | Related |
|---|---|---|---|
DRPT-03-001 |
MUST | Human-readable output MUST render actors and target objects by short, friendly names (e.g. "Finder", "report", "note"), MUST NOT use bare URIs or opaque UUID identifiers as the primary noun in event summaries, and SHOULD derive actor labels from the actor directory name. Full identifiers MAY be retained as secondary detail (e.g. a tooltip, title attribute, or trailing reference column). Per IDEA-1286: prefer "Finder submitted report" over "https://…/actors/finder submitted urn:uuid:…". |
|
DRPT-03-003 |
MUST | The tool MUST build a URI-to-display-name map during timeline construction by scanning all payload snapshots for actor objects (type Organization, Person, Service, Application, Group, Actor) that carry both an id (URI) and a non-empty name field.Actor URIs may be UUID-based when the actor was created with a generated identifier rather than a slug. Without name resolution, friendly_actor_name produces labels like "A1e7 421f B727" that violate DRPT-03-001's prohibition on opaque identifiers. The name field is present on inline actor objects in wire payloads (e.g. Organization objects in Invite or Accept activities), making payload-scan the correct extraction point. |
Extends: DRPT-03-001 |
DRPT-03-002 |
MUST | Event summaries MUST map the semantic type to a concise active-voice phrase (subject verb object) by looking up the matching SemanticEntry in SEMANTIC_REGISTRY and rendering entry.phrase via str.format_map().Replaces the tool-local _EVENT_PHRASES dict (Concern #1675). Deriving phrases directly from SemanticEntry.phrase (SE-07-001) guarantees that new semantics are automatically covered and eliminates the drift risk. |
Refines: DRPT-03-001 |
DRPT-03-004 |
MUST | When rendering a phrase template, the tool MUST supply slot values for {actor}, {object}, and {target} drawn from the event record's resolved display labels. Missing slots (e.g. {context} when no context was recorded) MUST render as an em-dash ("—") rather than raising a KeyError.CaseLedgerEventRecord is constructed from JSONL data and may not carry all slot values present in a live VultronEvent. A defaultdict fallback ensures phrases for multi-party semantics (e.g. {actor} invited {object} to {target}) degrade gracefully to "{actor} invited {object} to —" when the destination is absent rather than surfacing a runtime error. |
Refines: DRPT-03-002 |
DRPT-03-005 |
MUST | The tool MUST NOT maintain a local phrase table parallel to the SEMANTIC_REGISTRY.A local duplicate phrase table diverges from the registry over time; prohibiting it ensures all phrase coverage comes from SemanticEntry definitions per SE-07-001. |
|
DRPT-03-006 |
MUST | When no SEMANTIC_REGISTRY entry exists for a semantic type (e.g. data from a future protocol version), the tool MUST fall back to a humanized form of the raw semantic-type string.Graceful fallback prevents rendering failures when new semantic types are introduced before the registry is updated. |
|
DRPT-03-007 |
MUST | Actor labels MUST use the resolved display name from the URI-to-display-name map when available. Resolved display names prevent opaque UUID-based actor labels in output, satisfying DRPT-03-001's prohibition on bare identifiers. |
|
DRPT-03-008 |
MUST | Actor labels MUST fall back to the URI path-segment heuristic only when no entry is found in the URI-to-display-name map. The heuristic fallback provides a best-effort human-readable label for actors whose name was not captured in any payload snapshot. |
Rendering
| ID | Priority | Requirement | Related |
|---|---|---|---|
DRPT-04-001 |
MUST | The tool MUST render a markdown table report (one row per canonical timeline event) covering the distilled fields, including a per-actor replica-presence matrix column group. | |
DRPT-04-002 |
MUST | The tool MUST render a self-contained static HTML report file with all styling inlined (no external CSS, JS, fonts, or network assets), so the artifact renders identically offline and can be archived or shared as a single file. Client-side-JS parsing was considered and rejected to keep parsing logic in one place (Python), keep output deterministic and testable, and avoid a JS toolchain. |
|
DRPT-04-003 |
MUST | In the HTML report, the per-actor replica-presence matrix MUST be rendered as a per-actor checkbox/emoji cell per event, showing a present/absent indicator for each actor column (e.g. an event present in three of four actors' replicas shows three checks across four columns). | |
DRPT-04-004 |
MUST | The output format MUST be selectable (e.g. --format markdown|html), and the tool MUST write the report to a file (path configurable). |
|
DRPT-04-005 |
SHOULD | After writing an HTML report, the tool SHOULD open it in the default browser via webbrowser.open(), and MUST provide a flag (e.g. --no-open) that suppresses the browser launch (required for non-interactive and CI use). |
|
DRPT-04-006 |
SHOULD | Each per-case section SHOULD display the time range of its events as Time range: {first} – {last}, where {first} and {last} are the minimum and maximum receivedAt timestamps (full ISO 8601 strings) among the case's canonical events.When reviewing reports from CI runs or comparing across scenarios, knowing the time range at a glance (e.g. "15:31:20Z – 15:31:27Z") is more useful than counting rows. Per-case placement is necessary because log_index restarts at 0 per case and cases may span non-overlapping time windows (DRPT-02-006). |
Extends: DRPT-04-001 Extends: DRPT-04-002 |
DRPT-04-007 |
MUST | The Time column header MUST be labelled "ΔT". Absolute timestamps repeat the date and timezone on every row, making it hard to see the pacing of events. A delta-T column lets readers immediately spot where the protocol paused or accelerated. DRPT-04-006 already provides the absolute-time anchor (first/last timestamps in the section header), so the table body can omit redundant full timestamps without losing traceability. |
Extends: DRPT-04-001 Extends: DRPT-04-002 Extends: DRPT-04-006 |
DRPT-04-008 |
MUST | The time range line MUST be omitted when no events in the case carry a receivedAt timestamp.Rendering a time range with no data would display a meaningless or error-prone placeholder. |
|
DRPT-04-009 |
MUST | When all events in a case share the same receivedAt timestamp, both time range bounds MUST be shown ({ts} – {ts}).Showing both bounds even when equal makes the format unambiguous and avoids special-casing that could hide single-event cases. |
|
DRPT-04-010 |
MUST | In markdown output, the time range MUST appear as a bullet line between the case heading and the event table. Consistent placement ensures the time range is visible as a section-level annotation rather than embedded in the table. |
|
DRPT-04-011 |
MUST | In HTML output, the time range MUST appear as a <p class="meta"> element between the <h2> heading and the <table>.The meta class allows consistent styling of section-level metadata across cases in the HTML report. |
|
DRPT-04-012 |
MUST | Each Time cell MUST display a delta from the previous row's receivedAt timestamp as an adaptive duration string: +Ns when under 60 seconds, +Nm Ns when under one hour, and +Nh Nm Ns for one hour or more (no day component).Adaptive formatting makes small and large deltas equally readable without wasting space on zero-valued time components. |
|
DRPT-04-013 |
MUST | The first row that carries a receivedAt timestamp MUST show +0s in the Time cell.The first timed row anchors the delta sequence; showing +0s makes the anchor explicit rather than leaving the first cell empty. |
|
DRPT-04-014 |
MUST | Rows without a receivedAt value MUST show — in the Time cell.An em-dash distinguishes missing-timestamp rows from rows with a genuine zero delta. |
|
DRPT-04-015 |
MUST | In the HTML renderer, the full ISO 8601 receivedAt string MUST be retained as a title= tooltip on the Time <td> element.The tooltip preserves the absolute timestamp for hover inspection without cluttering the table cell. |
|
DRPT-04-016 |
MUST | In the markdown renderer, the absolute receivedAt timestamp MUST NOT appear in the Time column table cell body.The per-case time-range line (DRPT-04-006) provides the absolute-time anchor; repeating full timestamps in every cell is redundant. |
Testing
| ID | Priority | Requirement | Related |
|---|---|---|---|
DRPT-05-001 |
MUST | Unit tests MUST cover JSONL-to-distilled-model parsing against fixtures that include both camelCase and snake_case field spellings, and MUST assert field extraction, merge/de-duplication by entry_hash, and the per-actor replica-presence computation. |
|
DRPT-05-002 |
MUST | Unit tests MUST cover both renderers: given a fixed list of distilled events, assert the markdown table structure and assert that the HTML output is well-formed and self-contained (references no external assets). | |
DRPT-05-003 |
MUST | A CLI smoke test MUST invoke the entry point against a tiny fixture directory, assert exit code 0, assert an output file is written, and assert that --no-open suppresses any browser launch. |
|
DRPT-05-004 |
MUST | Unit tests MUST cover actor name resolution (DRPT-03-003): assert that collect_actor_names extracts URI-to-name mappings from inline actor objects in payload snapshots, that build_timeline populates actor_display_name on events whose actor URI is in the map, and that actor_label returns the display name rather than a hex-fragment label for UUID-based actor URIs. |
Verifies: DRPT-03-003 |
DRPT-05-005 |
MUST | Unit tests MUST cover the _format_delta helper directly: assert +0s when prev_received_at is None and received_at is set (first-row anchor), assert — when received_at is None, assert — when both are None, and assert correct adaptive formatting for sub-minute (+Ns), sub-hour (+Nm Ns), and multi-hour (+Nh Nm Ns) deltas. |
Verifies: DRPT-04-007 |
DRPT-05-006 |
MUST | Renderer tests MUST assert that the Time column in the table body contains delta strings (e.g. +0s) rather than ISO 8601 timestamps.Verifies that neither renderer leaks absolute timestamps into the Time cell body, per DRPT-04-016. |
|
DRPT-05-007 |
MUST | HTML renderer tests MUST assert that the full ISO 8601 receivedAt timestamp is retained as a title= tooltip on the Time <td> element.Verifies DRPT-04-015: the absolute timestamp is preserved for hover inspection even though it does not appear in the cell body. |
Dispatch Routing
After semantic extraction, the dispatcher routes VultronEvent domain objects to the appropriate use-case class. The dispatcher may execute use cases synchronously (DirectActivityDispatcher) or asynchronously (queue-based).
File ID: DR
Dispatcher Protocol
Handler Lookup
| ID | Priority | Requirement | Related |
|---|---|---|---|
DR-02-001 |
MUST | The system MUST look up use-case classes by semantic type using the mapping returned by use_case_map() |
Derives from: HP-03-001 |
DR-02-002 |
MUST | The mapping returned by use_case_map() MUST contain an entry for every MessageSemantics value whose SEMANTIC_REGISTRY entry names a use case, and none for a value the registry recognizes but no use case handles (SE-05-002) |
Derives from: SE-05-002 |
Direct Dispatch Implementation
| ID | Priority | Requirement | Related |
|---|---|---|---|
DR-03-001 |
MUST | The DirectActivityDispatcher MUST execute handlers synchronously | |
DR-03-002 |
MUST | The DirectActivityDispatcher MUST NOT catch handler exceptions. It MUST let every exception a handler raises propagate to its caller. A semantic type with no use case raises nothing: the dispatcher reports it as a REFUSED "no use case found" verdict, logged at INFO (SE-05-002).Catching and logging belongs at the adapter boundary that decides what a failure means for the inbox item (IE-06-004), not in core: a blanket catch in the dispatcher would break CS-23-001 (no broad except outside a BT node's update()) and HP-05-001 (handlers raise for unrecoverable errors), and would turn a programming error into a silent verdict. The dispatcher routes by semantics alone, so an activity that names no case reaches its handler, which refuses it (HP-05-002). |
Depends on: CS-23-001 Depends on: HP-05-001 |
Async Dispatch Implementation
| ID | Priority | Requirement | Related |
|---|---|---|---|
DR-04-001 |
SHOULD | An async dispatcher SHOULD queue activities for processing | |
DR-04-002 |
SHOULD | An async dispatcher SHOULD process activities in FIFO order |
Docs Site Build and Publication Workflows
Requirements for the GitHub Actions workflows that build the MkDocs site and publish it: the review workflow .github/workflows/docs-build-check.yml, which runs on pull requests and verifies the site builds cleanly and that its links and URLs resolve, and the publication workflow .github/workflows/deploy_site.yml, which builds the site and uploads it to GitHub Pages. DOCBW-01, DOCBW-02 and DOCBW-05 name and configure the review workflow; DOCBW-03 states the build and gate obligations every site-building workflow carries; DOCBW-06 states what is specific to publication. The workflows are distinct from python-app.yml (Python unit tests and linting) and .github/workflows/demo-integration.yml (demo container integration tests, specified in specs/demo-ci.yaml).
File ID: DOCBW
Review Workflow File Name and Display Name
| ID | Priority | Requirement | Related |
|---|---|---|---|
DOCBW-01-001 |
MUST | The workflow file MUST be named .github/workflows/docs-build-check.yml. |
|
DOCBW-01-002 |
MUST | The workflow name: field MUST be Docs Build and Link Check. |
Trigger Conditions
Job Structure
| ID | Priority | Requirement | Related |
|---|---|---|---|
DOCBW-03-001 |
MUST | The workflow MUST contain a single job that runs both the build-site and link-check phases sequentially, sharing the same runner filesystem to avoid artifact upload/download overhead. | |
DOCBW-03-002 |
MUST | The Build Site step MUST run on every workflow trigger regardless of which paths changed, so that Python-macro and plugin failures introduced by pyproject.toml or mkdocs.yml changes are always caught. |
|
DOCBW-03-004 |
SHOULD | The workflow SHOULD validate that maintainer-focused docs remain buildable. Pages under docs/developer/ are declared as draft_docs in mkdocs.yml and are served by mkdocs serve but excluded from production builds; CI validates the production build only.The former mkdocs.dev.yml overlay build was removed when docs/developer/ was moved from exclude_docs to draft_docs. Draft pages are excluded from mkdocs build regardless of config file, so a separate dev-overlay CI step no longer provides value. |
|
DOCBW-03-005 |
MUST | A withheld-artifact step MUST run after the Build Site step and MUST fail the workflow when any artifact declared withheld produced files in site/. It MUST also fail when site/ is absent or empty, and when a withheld declaration matches no file in the source tree.A decision not to publish is a claim, and nothing compared it to the build output. docs/ns/index.md carried draft: true — a Material blog-plugin key, not an MkDocs one — so it read as a suppression while the build published site/ns/. Checking the outcome rather than the mkdocs.yml mechanism also catches a mis-anchored pattern. The empty-site/ and stale-declaration failures exist because a gate that resolves no targets and passes is worse than no gate (DF-09-009). |
|
DOCBW-03-006 |
MUST | Every workflow that builds the site MUST run the DOCBW-03-005 step, including .github/workflows/deploy_site.yml, where it MUST sit between the build step and the Pages artifact upload.deploy_site.yml is the workflow that actually publishes: it builds site/ on a push to publish and uploads it to Pages. A gate present only in the pull-request workflow does not bind the publishing path, so a withheld artifact reintroduced by a direct push to publish, or by a plugin or dependency change that alters the build, would ship. Placing the step before the upload means the deploy fails rather than publishing an artifact the project declared withheld. |
|
DOCBW-03-007 |
MUST | A reference-axis step MUST run after the Build Site step on every trigger, with no step-level if: condition, and MUST fail the workflow when any internal reference in the built site/ tree resolves to a path the build did not produce. It MUST evaluate every built HTML file rather than only those reachable from an entry page, MUST fail when site/ is absent or empty, and MUST run in every workflow that builds the site, on the same terms as DOCBW-03-006.The reference axis went unchecked: docs/ns/ moving to draft_docs (#3549) left What's New advertising ../../ns/, a well-formed path with no target (#3574). --strict cannot see it: markdown-exec renders on a child Markdown instance with no relative-link treeprocessor. Checking resolution subsumes form signatures like a .md suffix; a crawl misses every page nothing links to. Other clauses: notes/documentation-strategy.md. |
|
DOCBW-03-008 |
MUST | A continuity step MUST run after the Build Site step and MUST fail the workflow when a URL that any earlier publish of the site served resolves to neither a built page nor a redirect whose chain ends at a built page, unless a withheld declaration (DOCBW-03-005) covers it. The record of served URLs MUST be committed, MUST only grow, and MUST NOT be derived at check time from the branch the publish replaces. The step MUST fail when site/ is absent or empty or the record is empty, and MUST run in every workflow that builds the site on the same terms as DOCBW-03-006.Between publish and main, over a hundred pages moved or were withdrawn, and a publish would have 404ed each URL (#3556). --strict checks links within one build, not URLs an earlier build served. The record is committed because its evidence, the publish branch, is gone once publish advances. A withdrawn page is excluded, not redirected, so a retirement is not disguised as a move. Other clauses: notes/documentation-strategy.md. |
|
DOCBW-03-009 |
MUST | Every workflow that builds the site MUST run mkdocs build --strict, so that any warning the build emits fails the workflow.mkdocs.yml raises dead in-page anchors and un-navved pages to warn so they would fail a strict build (#1304, #3027), and no workflow ran one: the warnings printed and the job stayed green (#3051). Strictness is not link coverage — --strict sees only links MkDocs parsed, which is why DOCBW-03-007 checks the built output. |
|
DOCBW-03-010 |
MUST NOT | A site build that DOCBW-03-009 requires to be strict MUST NOT also pass --quiet (or -q).--quiet raises the mkdocs logger to ERROR before strict mode's warning counter sees a record, so mkdocs build --strict --quiet exits 0 over warnings. The flag reads as cosmetic and switches the gate off. |
|
DOCBW-03-011 |
MUST | mkdocs.yml MUST set validation.links.anchors and validation.nav.omitted_files to warn, so that a strict build fails on a dead in-page anchor link and on a page absent from both the nav and not_in_nav.info is below what --strict counts, so downgrading either setting turns its check off as silently as dropping --strict would, and nothing in the build output says so. |
|
DOCBW-03-012 |
MUST | On every pull request whose base branch is publish, whatever paths it changes, the review workflow MUST fail when a page its merge result builds is absent from the DOCBW-03-008 record of served URLs, deciding which pages are built by the same rule that extends the record. The step MUST fail when the merge result builds no pages, and MUST NOT run on pull requests to any other base branch.The record stays complete only if each publish is recorded before it ships, and nothing enforced that (#3728); recording every incoming publish covers all earlier ones. The merge result is checked because it is what publish will serve once the pull request merges. A path filter would skip a release that changes no docs while pages from an earlier unchecked publish are still unrecorded. On a pull request to main a new page is not yet due, so the step would fail every page addition. publish has no required status checks, so the gate holds through the rule that a pull request never merges on a red check; a direct push or manual deploy goes unchecked, an accepted gap. See notes/documentation-strategy.md. |
Depends on: DOCBW-03-008 |
Security Compliance
Site Publication
Obligations of the publication workflow, .github/workflows/deploy_site.yml, beyond the build and gate obligations DOCBW-03 places on every site-building workflow. What triggers publication (a push to a release branch, a cadence on main, or both) is an open decision, tracked as Concern #3560, and is deliberately not specified here.
| ID | Priority | Requirement | Related |
|---|---|---|---|
DOCBW-06-001 |
MUST | The publication workflow MUST be the only workflow that uploads the site to GitHub Pages. A gate is a property of the paths on which it runs. With one uploader, every requirement placed on the publication workflow binds every path by which the site reaches readers; a second uploader would be a path no DOCBW-06 obligation covers. |
|
DOCBW-06-002 |
MUST | In the publication workflow, the publication gates (DOCBW-03-005, DOCBW-03-007 and DOCBW-03-008, run through the .github/actions/check-site-publication composite action) MUST run after the site build and before the Pages artifact upload.After the upload the artifact is already out, so a gate placed later checks a site that has shipped; before the build, site/ does not exist. The ordering is the gate. |
Refines: DOCBW-03-006 |
DOCBW-06-003 |
MUST | The publication workflow MUST notify on failure and close the failure notice on a later success, through the notify-failure composite action (CISEC-05-001).A failed publish leaves the live site stale with no failing pull request to notice it, so the failure has to be reported by the run itself. |
Implements: CISEC-05-001 |
DOCBW-06-004 |
MUST | Every workflow that builds the site MUST build it through one shared composite action that fixes the checkout (history depth and tags), the dependency set and the mkdocs build command. A workflow MAY add steps after the shared build, and MUST NOT run a mkdocs build of its own.A review build is evidence about the published site only when it builds the same thing. The review and publication workflows have diverged on the installed dependency group, on whether git tags are fetched, and on the build flags, so a green review did not prove the published site. |
Refines: DOCBW-03-009 |
Duration
Requirements for a canonical representation of time durations independent of start/end timestamps. Applicable wherever a duration must be exchanged in JSON, such as embargo policy records and embargo initialization at case creation.
File ID: DUR
Python / Pydantic v2 Mapping
| ID | Priority | Requirement | Related |
|---|---|---|---|
DUR-05-002 |
MUST | Duration fields MUST serialize to ISO 8601 duration strings at the JSON wire layer; the internal timedelta MUST NOT be serialized as a plain number |
Embargo Management Behavioral Requirements
Event-Condition-Action behavioral requirements for Embargo Management (EM). Each group is triggered by either a received EM message or a local EM state transition, and specifies typed preconditions, ordered steps, and postconditions that implementations must satisfy.
File ID: EMB
Actor-Voluntary Embargo Termination (Trigger Side)
Trigger-side factory for voluntarily ending an active embargo (e.g., fix deployed or ready). "The actor" here is the actor enacting the CASE_MANAGER role: the factory's canonical case-state write (EM → EXITED, ledger commit, teardown announce) is gated on CVDRole.CASE_MANAGER (terminate_embargo_bt runs ResolveCaseManagerNode before the state mutation) and is authorised by the Case Owner by default (CM-02-005; EmbargoTeardownAuthorizationGate defaults to RequireCaseOwnerApproval, RSH-02-002). A non-manager Participant that wishes to end an embargo does not run this trigger to write canonical state; it initiates termination by reporting the condition or notifying intent (VP-11-003, VP-13-017/018), and terminates only its own compliance. See CONCERN-3280 for the reconciliation of the participant-facing protocol prose with this authority model.
Trigger: State Entered — EM.ACTIVE
| ID | Priority | Requirement | Related |
|---|---|---|---|
EMB-14-002 |
MUST | The create_terminate_active_embargo_tree factory MUST include an EmbargoExitPolicyGuard Evaluator call-out point that runs after the reason Selector and before OnEmbargoExit. The guard MAY veto termination regardless of the chosen reason. Separating the reason selection (actor intent) from the policy check (system authorization) allows external policy engines to block termination on grounds orthogonal to the stated reason (e.g., active revision negotiation, pending approvals) without coupling the guard logic to the reason semantics. ECA DetailsPreconditions - EM state is Active or Revise; reason Selector has succeeded - EM state: ACTIVE, REVISESteps 1. [system] Run EmbargoExitPolicyGuard call-out point → Returns SUCCESS (policy permits) or FAILURE (policy vetoes) Postconditions - SUCCESS: pipeline continues to OnEmbargoExit and terminate_embargo_bt. FAILURE: termination blocked; actor may retry with override. |
Refines: EMB-14-001 |
Inbound Embargo-Response Decision (Received Side)
Behaviors triggered when an actor receives an inbound embargo overture and must decide how to respond. Two overture flows are distinguished: (Flow A) an EM-level embargo proposal (EP) proposing terms, whose acceptance moves the shared EM state; and (Flow B) a PEC-level invitation (InviteToEmbargoOnCase) asking the actor to become a signatory to an embargo, whose acceptance advances that participant's Participant Embargo Consent (PEC) state. The pre-PEC simulator conflated these into a single _ChooseEmProposedResponse fallback; the production protocol separates the shared EM lifecycle (EMB-01..EMB-09) from per-participant consent (CM-18, ADR-0048). This group specifies a single decision seam layered over both flows: it selects the response (accept / defer-to-owner / reject / counter) while delegating the mechanical transition to the existing received and trigger BTs. The mechanics already exist (invite/accept/reject trees, propose/accept/reject trigger use cases); what is missing is the decision layer that chooses among them, with a default-accept policy and a plug-in seam for owner adjudication.
Trigger: Message Received — EP | InviteToEmbargoOnCase
| ID | Priority | Requirement | Related |
|---|---|---|---|
EMB-15-006 |
MUST NOT | When the deciding actor holds CVDRole.CASE_OWNER, the owner's stated response is authoritative and the seam MUST NOT require external approval.The CASE_OWNER is the human decision-maker whose choice is gospel; requiring external approval for the owner's response would violate the authorization model. |
Refines: EMB-15-002 |
Embargo Policy
Requirements for a standardized embargo policy record associated with an Actor profile. Allows actors to declare their default embargo preferences so that coordinators can evaluate compatibility before proposing an embargo or inviting an actor to a case.
File ID: EP
API Endpoint
| ID | Priority | Requirement | Related |
|---|---|---|---|
EP-02-004 |
MUST | A publish or replace at PUT /actors/{actor_id}/embargo-policy MUST NOT overwrite an update to the actor's profile that was written after the request read it. The write MUST succeed only if the stored profile is still the one the request read; otherwise the request MUST re-read the profile and re-apply its terms, and when the profile keeps changing under it for a bounded number of attempts it MUST be refused with 409 Conflict and MUST write nothing.The policy is a field of the profile (EP-01-001), so a publish rewrites the whole profile, and a plain read-then-save loses a write landing between the two without anyone being told (#4102). Re-applying the terms to a fresh read is safe because a publish changes only the policy field; the bound keeps a profile under continuous write from holding the request open. |
Refines: EP-02-005 |
Default Embargo Semantics
| ID | Priority | Requirement | Related |
|---|---|---|---|
EP-04-013 |
MUST NOT | This codebase MUST apply both the PROPOSE and ACCEPT state machine triggers atomically within InitializeDefaultEmbargoNode, and MUST NOT persist the intermediate EM.PROPOSED state.How this codebase meets EP-04-002; split from it so EP-04-002 states only what other participants observe (MS-12-010). |
Implements: EP-04-002 |
EP-04-014 |
MUST | This codebase MUST carry the proposed EmbargoEvent of EP-04-004 inline as the proposedEmbargo of the Offer(VulnerabilityReport), not by reference.How this codebase meets EP-04-004; split from it so EP-04-004 states only what other participants observe (MS-12-010). The CASE_MANAGER has never seen the Reporter's event, and this codebase does not yet dereference (AKM-05-003), so the event travels inline. That is a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: EP-04-004 |
EP-04-010 |
MUST NOT | Implementations MUST NOT conflate the actor default with the protocol default. The two MUST be carried under distinct names, so that a consumer can distinguish "the CASE_OWNER published this duration" from "the CASE_OWNER published nothing". The protocol default MUST be the single source of the fallback duration: no other implicit default embargo duration may be carried anywhere in the implementation._preferred_embargo_duration() returned its fallback into the same blackboard key (default_embargo_duration) that a published EmbargoPolicy filled, so no downstream code could tell the two apart. That conflation is why a silent 90-day fallback survived in contradiction of three documents. Additionally, that function selected policies[0] from an unordered list, making the applicable policy arbitrary when an actor's store holds more than one. See ADR-0096. |
|
EP-04-012 |
MUST | Creation-time embargo initialization MUST run at most once per case. A Create(as_CaseProposal) that resolves to an existing VulnerabilityCase MUST NOT run it again in any EM state other than NONE — PROPOSED, ACTIVE, REVISE, or EXITED — and any embargo terms carried on such a proposal MUST NOT be reconciled against the case: the embargo belongs to the case, not to the report, so terms that conflict with the case lose to the case. A case still at EM.NONE has never been initialized (EP-04-002 never persists PROPOSED), and initialization MUST proceed for it."Initialized" cannot be read from the case's active-embargo reference, because termination clears that reference while the EM state stays EXITED. A guard keyed on the reference reads an exited case as uninitialized and runs the creation arm, which stores a fresh EmbargoEvent before the EM machine refuses the EXITED → PROPOSED step it has no transition for — an orphan write and an unanswerable proposal (#3986). The EM state is the evidence: the machine never returns to NONE once it has left it, so "has left NONE" is exactly "initialization has run", and NONE still admits the case the delivery is creating. |
Refines: EP-04-002 — The never-persisted PROPOSED state is what makes "has left NONE" a clean reading. Depends on: CP-05-006 — The reuse path this rule guards is the duplicate-proposal recovery path. Depends on: EMB-18-001 — The guard reads EM state; it never writes it. |
Open Proposal Resolution Order
How a Participant resolves two or more simultaneously open embargo proposals: earliest expiration first, with the remainder handled as revisions. Codified by ADR-0100, which also records why there is no multi-candidate poll activity. The heuristic itself predates this group as normative prose in docs/topics/process_models/em/defaults.md; stating it here is what makes it testable. Source: CONCERN-3433.
| ID | Priority | Requirement | Related |
|---|---|---|---|
EP-08-003 |
MUST | The record of open embargo proposals MUST NOT retain a proposal that has been accepted or rejected, or whose embargo has terminated. An implementation that records open proposals MUST take an entry out of that record when the proposal is decided. EP-08-002 orders the set of open proposals, so a set that retains decided proposals can select one that is no longer open. Ordering an unpruned record is not a weaker guarantee than ordering a pruned one — it is a different and wrong answer, which is why the retention rule is stated alongside the ordering rule rather than left as hygiene. |
Constrains: EP-08-002 |
Encryption
Requirements for encrypted messaging and actor key management using ActivityPub extension conventions. Ensures that sensitive vulnerability information can be protected in transit between actors.
File ID: ENC
Decryption Pipeline
| ID | Priority | Requirement | Related |
|---|---|---|---|
ENC-03-001 |
MUST | Decryption of inbound encrypted activities MUST occur in the inbox handler before the activity is passed to semantic extraction or dispatch | Depends on: IE-04-001 Depends on: SE-01-001 Implements: VP-15-004 |
ENC-03-002 |
MUST | If decryption fails, the inbox handler MUST return HTTP 400 with a structured error indicating decryption failure | Depends on: EH-05-001 Depends on: HTTP-03-001 |
ENC-03-003 |
MUST NOT | The decryption layer MUST be separate from semantic extraction logic; the two concerns MUST NOT be mixed |
Error Handling
Requirements for handling error conditions in the Vultron inbox handler, covering the exception hierarchy and error categories. Governs graceful degradation, clear error messages, system stability, and debuggability.
File ID: EH
Exception Hierarchy
Submodule Errors
Error Categories
Error Context
| ID | Priority | Requirement | Related |
|---|---|---|---|
EH-04-001 |
SHOULD | Exceptions SHOULD include contextual information |
Error Response Format
| ID | Priority | Requirement | Related |
|---|---|---|---|
EH-05-001 |
MUST | HTTP error responses MUST include a structured JSON body with at minimum the following fields: error (string — machine-readable error code from the VultronError hierarchy), message (string — human-readable description), and status (integer — HTTP status code mirroring the response status). |
Depends on: HTTP-03-001 |
EH-05-002 |
MUST | An HTTP error response reporting more than one distinct violation MUST include a details field: an array with one entry per violation. Each entry MUST carry the violation's human-readable message, and MUST identify whether the violation is a root cause or is derived from another reported violation. The message field MUST remain a human-readable rendering of the whole set, so a caller reading only message still sees every violation.Before this requirement a multi-violation rejection collapsed to the first violation's message, so a caller fixed one problem, resubmitted an atomically rejected request, and was told about the next one. Callers wanting individual violations had to parse message, which ISSUE-2112 noted breaks silently when the order of internal checks changes. details gives them a stable surface; keeping the full set in message means adding the field does not make existing text-only consumers worse off. |
Refines: EH-05-001 Depends on: EH-07-001 |
Error Logging
| ID | Priority | Requirement | Related |
|---|---|---|---|
EH-06-001 |
MUST | All errors MUST be logged at the level defined by SL-03-001 log level semantics | Depends on: SL-03-001 |
Multi-Violation Rejection
| ID | Priority | Requirement | Related |
|---|---|---|---|
EH-07-001 |
MUST | A validation boundary that rejects its input as a unit MUST report every violation it can recognise in that input, not only the first. Rejecting atomically does not license reporting a single reason: no partial progress was accepted, so a caller told one reason at a time gains nothing from the round trip. Atomicity governs what is written; diagnostic completeness governs what is reported. The two are independent, and conflating them produced a fix-one-resubmit loop on the add-participant-status trigger path (ISSUE-2112). |
|
EH-07-002 |
MUST | When a validation boundary reports multiple violations, each violation MUST be classified as root or derived. A rule reading a single dimension is always root. A rule reading more than one dimension is derived when any dimension it reads already carries a single-dimension violation, and root otherwise. Dimension overlap is a proxy for the real test, which is remediation: a violation is derived when the remediation the caller is expected to apply to the overlapping single-dimension violation also clears it. The proxy holds only while that premise does. For a transition fault the expected remediation is asserting a legal value, which clears the entailment that read the illegal one. For a role gate the expected remediation is withdrawing the assertion, not acquiring the role — a participant does not hold a dimension it has no role for at all (a non-DEPLOYER's d is absent, not initial; ADR-0075) and an actor cannot grant itself a role. Any rule family added later whose expected remediation is neither of those MUST be checked against the remediation test before relying on the overlap proxy.One bad dimension frequently entails further violations, so unranked thoroughness degrades into a wall of errors that one fix clears most of. The classification test is dimension overlap rather than a rule-to-rule dependency graph, so a newly added rule is classified correctly by construction and the labelling cannot go stale. The root multi-dimension case is the informative one: every dimension moved legally on its own and the combination is impossible. The remediation premise is stated normatively above because it is load-bearing and was previously left implicit: three separate reviewers read the overlap rule as a pure graph property, concluded a role gate mislabels its entailments, and built a repro that put a participant on a dimension it had no role for — a state ADR-0075 makes unreachable. The finding dissolved each time, but only after re-deriving the domain premise the requirement never stated. |
Refines: EH-07-001 |
EH-07-003 |
MUST | A VultronError subclass raised to report multiple violations MUST carry them as structured data on the exception, and MUST render the whole set in str(). Callers MUST NOT be required to parse a joined message to recover the individual violations.DemoFailureError already establishes this shape for the demo runner (DEMOCI-01-003). Reusing it rather than inventing a second aggregation mechanism keeps one pattern for accumulate-all-then-fail, and lets the structured list reach the HTTP layer (EH-05-002) without new plumbing. |
Refines: EH-04-001 Refines: EH-01-002 |
Event-Driven Control Flow
Defines the event-driven processing model for Vultron actors: how primary events enter the system, how cascading consequences are produced automatically, and what constraints apply to demo scripts and integration tests.
File ID: EDF
Definitions
Cascade Implementation
External Decision Nodes
| ID | Priority | Requirement | Related |
|---|---|---|---|
EDF-04-002 |
MUST NOT | An external decision node MUST NOT return py_trees.common.Status.RUNNING to represent awaiting input; it MUST request the input it needs and terminate.The execution model cannot host RUNNING: BTBridge.execute_tree treats root RUNNING as "continue ticking", exhausts its budget, logs ERROR, returns FAILURE, then discards the tree — so no node survives to be resumed. Waiting is instead expressed by requesting the input and terminating (ASK-01-002). |
Refines: ASK-01-003 |
EDF-04-004 |
MUST | Trigger endpoints that correspond to external decision node resolutions MUST be documented as such; their role is to supply the missing input, not to choreograph a cascade that should be automatic. | |
EDF-04-005 |
MUST NOT | An external decision node MUST NOT succeed or fail without having requested the input it needs. Silent success or failure hides the fact that input was never solicited, causing the BT to proceed as if the decision was made when it was not. Amended by ADR-0080: because a decision point terminates rather than waits, the observable that distinguishes "asked and waiting" from "gave up" is the emitted request — a terminal status with no request emitted is the silent failure this forbids. This is the defect RequireCaseOwnerApprovalNode exhibits: it returns FAILURE without ever asking. |
Refines: EDF-04-002 |
EDF-04-006 |
MUST NOT | An external decision node MUST NOT be modeled as an absence of code. An absent node makes the waiting point invisible to the BT structure, preventing inspection and making the decision requirement undetectable. |
Refines: EDF-04-002 |
Demo Constraints
| ID | Priority | Requirement | Related |
|---|---|---|---|
EDF-05-001 |
MUST | Demo scripts MUST inject only primary events into the system. | |
EDF-05-002 |
MUST NOT | Demo scripts MUST NOT trigger intermediate cascade steps manually; all cascade steps that are not external decision nodes MUST complete automatically as a result of the primary event. | |
EDF-05-003 |
MUST | Demo scripts MUST verify correctness by observing externally visible system state (e.g., DataLayer contents, activities queued in the outbox) after the primary event and its cascades have settled. "Settled" is not a matter of elapsed time. EDF-06 defines normatively what counts as evidence that a cascade has settled; read this requirement together with EDF-06-001. |
Refines: EDF-06-001 — EDF-06-001 supplies the operational definition of "settled" that this requirement assumes. |
EDF-05-004 |
MUST NOT | Demo scripts MUST NOT assert on internal BT execution state (e.g., node status, blackboard values) as a substitute for observing external system state. | |
EDF-05-005 |
SHOULD | A demo that must manually trigger intermediate steps to reach its expected final state SHOULD be treated as evidence of a cascade automation gap that requires a BT fix, not as correct demo behavior. |
Causal Gating in Demo Scenarios
| ID | Priority | Requirement | Related |
|---|---|---|---|
EDF-06-002 |
MUST | The gate predicate MUST be evaluated against the state of the actor that owns the awaited effect, read from that actor's own container. Observing the effect on the sending actor's container MUST NOT be accepted as evidence that the receiving actor committed it. Each actor holds an independent DataLayer replica. A sender-side observation proves only that the sender emitted the activity, not that the recipient processed it. |
|
EDF-06-003 |
MUST NOT | A gate MUST NOT be placed on an observable that becomes true synchronously during the triggering request when the awaited effect is committed asynchronously after that request returns. Bug #2134: engage-case was gated on "the case object exists" — which resolves synchronously during validate-report — rather than on "this participant reached RM.VALID", which commits after the HTTP 202 returns. A synchronously-available observable is a proxy for the cause having started, not for its having completed. |
|
EDF-06-004 |
MUST | When a received-side use case creates a new activity in response to a received one (the forwarding or propose-then-execute pattern), the harness MUST discover the derived object by scanning the recipient's state for its semantic properties (activity type, target, and object), and MUST NOT poll for the identifier of the sender's original activity. Bug #2178: the demo polled the Coordinator's DataLayer for the id of the activity Vendor1 sent, but the CASE_MANAGER answers Vendor1's ownership-transfer proposal by making its own Offer(VulnerabilityCase) under an id it derives (CM-21-005, ADR-0148), and that Offer is what reaches the Coordinator. Polling for the antecedent's identity can never observe the consequent. |
|
EDF-06-005 |
MUST | An unmet causal gate MUST prevent execution of the steps that depend on it. Recording the failure and continuing to the dependent step is not sufficient. A gate that records a failure but lets the dependent step run produces a cascade of secondary failures that masks the first one, and makes the gate indistinguishable from a comment. This refines the accumulate-and-continue rule of DEMOCI-01-003, which governs reporting rather than control flow. |
Refines: DEMOCI-01-003 — Accumulation of failures is preserved; only the control flow of dependent steps is constrained. |
EDF-06-006 |
MUST | A wait that is irreducibly temporal — service liveness probes, protocol deadlines such as embargo expiry, and transport retry backoff — MUST be identified as temporal at its call site and MUST NOT be counted as a causal gate. Not every wait is a defect. Naming the temporal waits explicitly keeps them from being "fixed" into meaningless causal gates, and keeps the causal-gate inventory honest about what it does and does not cover. |
|
EDF-06-007 |
SHOULD | A step whose causal precondition cannot be expressed as an observable predicate SHOULD be treated as evidence of a missing observable or an unautomated cascade, and SHOULD be recorded as such rather than replaced with a timed wait. Refines EDF-05-005 to the harness side: where EDF-05-005 detects missing cascade automation from a needed manual trigger, this detects it from an ungateable precondition. Some preconditions are genuinely unobservable today (an actor having processed a delivery leaves no completion record); those gaps belong in the record, not behind a sleep. |
Refines: EDF-05-005 — Same diagnostic principle applied to harness gating rather than to manual cascade triggering. |
EDF-06-008 |
MUST | The timeout value of a temporal wait (EDF-06-006) MUST be accompanied by a comment at the call site that names the delivery chain being bounded and justifies the chosen value relative to worst-case BackgroundTasks delivery across the containers involved. A bare integer timeout (e.g. timeout_seconds=15.0) with no explanation is indistinguishable from an arbitrary guess. When the wait starts failing in CI, there is no basis for deciding whether to raise the budget, change the observable, or investigate a deeper delivery failure. The justification comment is the decision record that makes that choice tractable. |
Refines: EDF-06-006 — Adds a justification requirement to the identification requirement; both apply to every temporal wait. |
Handler Protocol
Handler use-case classes process VultronEvent domain objects and implement protocol business logic. All handlers follow a common contract defined by the UseCase Protocol.
File ID: HP
Handler Signature
| ID | Priority | Requirement | Related |
|---|---|---|---|
HP-01-001 |
MUST | All handler use-case classes MUST accept (dl, request) as __init__ parameters — dl a narrow persistence port (CasePersistence or CaseOutboxPersistence, DL-07) and request the handler's concrete VultronEvent subclass — and their execute() method MUST take no arguments |
|
HP-01-002 |
MUST | Handler use-case execute methods MUST return HandlerResult; None is not a permitted return typeThe return value has a consumer: the handler's HandlerDisposition is the only thing that can tell InboxOutcome whether an inbound assertion was applied, skipped, deferred, or refused (ADR-0095). A permitted -> None would be ambiguous between "nothing to report" and "not yet migrated" and would contradict UCORG-05-002. |
Implements: UCORG-05-002 |
HP-01-003 |
MUST | A handler MUST report a HandlerDisposition of REFUSED only when it rejected the inbound assertion; it MUST report SKIPPED for a legitimate no-op such as a duplicate or already-present record, and DEFERRED when it parked the item for later replay rather than declining it. A benign skip or a deferral MUST NOT be reported as REFUSEDNot every behavior-tree FAILURE is a refusal, and not every SUCCESS is applied work. received/status.py already special-cases CASE_STATUS_ALREADY_PRESENT to keep an idempotent skip from reading as a failure, and the ledger-sync buffer nodes return SUCCESS for an entry they have only parked. Conflating either with a refusal would promote it to InboxOutcome.status == "rejected" and produce false alarms; conflating a deferral with APPLIED or SKIPPED hides the fact that a replay is pending. See ADR-0095 and issue #2255. |
|
HP-01-004 |
MUST NOT | Handler use cases MUST NOT return or reference InboxOutcome or its processed/deferred/rejected vocabulary; mapping HandlerDisposition onto InboxOutcome is owned by DispatchNodeKeeps the inbox-pipeline vocabulary out of the core use-case layer. A handler reports what it did, in its own vocabulary; whether that becomes processed, deferred, or rejected is a pipeline concern, and two dispositions already collapse onto processed. Note this is a layering rule, not a claim about which outcomes a handler can cause: a handler can produce a deferral (HandlerDisposition.DEFERRED), it just must not say so in InboxOutcome's words. |
|
HP-01-005 |
MUST | A handler that receives a message it holds no role or addressing to act on MUST report REFUSED with a reason naming the missing role or addressing. That covers a CASE_MANAGER-addressed message at an actor that is not the case's CASE_MANAGER, and a copy of an activity whose to does not name the receiving actor. cc, bto and bcc address no Vultron recipient (OX-08-001, ADR-0109), so a cc-only receiver is such a copy, of an Offer(Report) (HP-09-001, HP-09-002, HP-10-001) as of any other. Such a receipt is not a legitimate no-op and MUST NOT be reported as SKIPPED; SKIPPED is reserved for a duplicate or otherwise idempotent re-delivery of a message the receiver was entitled to act onA role gate skips as SUCCESS so that the CASE_MANAGER's own failure stays distinguishable from a non-manager's no-op (BTND-07-005), and several handlers read that skip as "not my job" and reported SKIPPED, which DispatchNode records as processed. The receiver's inbox then showed a misaddressed message as a successful receipt. The disposition is the receiver's own report on its own handling (HP-01-004): it never says anything about another actor. A message the receiver could not act on was addressed to the wrong party by its sender — or, for a courtesy copy the protocol gives it no standing to act on, addressed in a way that asked nothing of it — and REFUSED with a reason is the record that says so. Under the narrowed SKIPPED, an addressee's first receipt of a message that is now pending its own decision (the Case Owner and Offer(CaseParticipant)) is APPLIED: it is neither a duplicate nor a refusal. So is a participant's first receipt of the CASE_MANAGER's direct notice that it was removed or reinstated (CM-31-006, CM-31-011): the participant is the notice's rightful addressee, stores it, and applies the move from its ledger entry (CM-31-007). See #3752 and #5010. |
|
HP-01-011 |
MUST | CreateReportReceivedUseCase and SubmitReportReceivedUseCase MUST declare the ANY_ACTOR entitlement kind for HP-01-010, not an exemption, and no other received use case MAY declare it.ANY_ACTOR states a decided rule with no guard to compose, so it is a kind rather than an exemption: the occasion table's "Sent by" can render it (MSM-08-005), and the exemption list keeps only the checks that are still missing. Restricting it to the two new-report use cases stops it from becoming an exemption under another name. |
Implements: HP-01-010 Refines: HP-01-007 |
Payload Access
| ID | Priority | Requirement | Related |
|---|---|---|---|
HP-04-001 |
MUST | Handlers MUST read activity data as typed fields of their VultronEvent subclass — including the core VultronActivity carried on activity when the semantic entry sets include_activity — and MUST NOT re-parse, re-extract, or otherwise re-read the inbound wire activity. Extraction and validation happen once, in the wire layer, before dispatch.The dispatcher hands a handler a fully validated domain event, so every field the handler needs is already typed, and required where the subclass declares it required. Reading the wire payload again would duplicate the single extraction seam (ARCH-03-001, ARCH-07-001) and reintroduce the optional-field guards that validate-at-edge removed (ADR-0032); core does not re-read wire activities for semantics (ADR-0035). |
Refines: ARCH-07-001 Depends on: ARCH-08-002 |
Logging
| ID | Priority | Requirement | Related |
|---|---|---|---|
HP-06-001 |
MUST | Handler entry MUST be logged at DEBUG level with the handler class nameDispatcherBase._handle() emits the entry line immediately before it calls execute(), so every handler gets it from one place and no handler carries its own copy. |
|
HP-06-002 |
MUST | Handlers MUST log state transitions at INFO level with before/after states | |
HP-06-003 |
MUST | Handlers MUST log errors at ERROR level with full context | Depends on: SL-03-001 Depends on: SL-04-001 |
Data Model Persistence
| ID | Priority | Requirement | Related |
|---|---|---|---|
HP-08-001 |
MUST | Persistence writes made on a handler's behalf MUST go through the persistence port's typed object methods — dl.save(obj) (upsert) or dl.create(obj) (insert) with a domain object — which validate and dehydrate the model; they MUST NOT build records by hand (object_to_record()) or call the record-level update(id_, record) compatibility method.save() and create() are the write paths that keep the stored record consistent with the domain model and that dl.read() can rehydrate (ADR-0034). The record-level method is retained only as a compatibility shim for the adapter layer and for the legacy generic UpdateObject / CreateObject BT nodes in vultron/core/behaviors/helpers.py. |
|
HP-08-002 |
MUST NOT | A handler's execute() MUST NOT mutate the DataLayer directly (save, create, update, delete); the write runs inside a BT leaf node reached through BTBridge, so it sits on the audited ledger-commit path (CLP-10-005, BT-15-001). |
Refines: CLP-10-005 Refines: BT-15-001 |
HP-08-003 |
MUST NOT | Pydantic validators MUST NOT overwrite existing field values during deserialization | |
HP-08-004 |
MUST | The DataLayer and handlers MUST treat object IDs as opaque URI strings | |
HP-08-005 |
MUST | Handlers that add status updates MUST append full status objects (not bare ID strings) to the case/participant status history lists | Depends on: CM-03-006 |
HP-08-006 |
MUST | The AddNoteToCaseReceivedUseCase handler MUST append the note's id to VulnerabilityCase.notes (a list of note ids) and commit the attachment as a ledger entry, persisting the Note object the Add(Note) carries before it appends the id. An Add(Note) introduces a new note: when a note is already stored under the note's id, the handler MUST refuse the Add before the commit, whatever the carried note says, unless the case's ledger already records that same Add, which it MUST report as a SKIPPED redelivery. It MUST NOT replace a stored note; a change to one would be an Update(Note), which this implementation does not route.Notes are persisted and linked to cases, consistent with the case management data model. The refusal keys on the id alone because the inbox's routing rebuild hands the handler the note stored under the id in place of the carried one (IO-03-004), so a rule comparing the two copies could never see a difference on the inbox path; that is how an Add naming another case's note attached it and sent its content to the target case's participants (#4831). |
Depends on: IO-03-004 |
HP-08-007 |
MUST | A received Add(Note) or Add(CaseStatus) whose object's context is not the Add's target case, or names no case, MUST be refused before the commit. The object checked is the one the handler would attach: the record stored under the object's id when one is held, else the carried copy.A note or status belongs to one case; attaching another case's object would commit it to the target case's ledger and send it to that case's participants (#4831). Checking the stored record matches what the inbox routes (IO-03-004). |
Refines: HP-08-006 |
HP-08-008 |
MUST | A received Add(ParticipantStatus) MUST act on the case named by the context of the receiver's stored record of the target participant, never on a case the status's context or the activity's context names. A status whose context is not that case MUST be refused before the commit, as HP-08-007 refuses an object of another case. When the receiver holds no record of the target participant, or the record names no case, the Add has no case and MUST be refused before the commit.The status is appended to the participant record the receiver holds, so the case that owns that record is the one changed. Letting the sender's status pick the case ran the sender guard, the commit and the case-status update against one case while the status landed on another case's participant (#4961). |
Refines: HP-08-007 Depends on: IO-03-004 |
History File Management
Defines the structure, location, entry format, and tooling for managing project history entries. For implementation and idea types whose --source resolves to a GitHub issue number, append-history posts the entry body as a comment on the corresponding GitHub issue rather than writing a file. learning and priority types continue to write per-entry write-once files under plan/history/, managed by the append-history CLI tool.
File ID: HM
Entry File Format
| ID | Priority | Requirement | Related |
|---|---|---|---|
HM-02-002 |
MUST | Valid type values MUST be defined as a StrEnum named HistoryEntryType in vultron/metadata/history/types.py. Current values are idea, implementation, learning, and priority. |
|
HM-02-005 |
MUST | Adding a new history entry type MUST only require adding a member to the HistoryEntryType enum; no other code changes are required for the tool to accept the new type. |
Refines: HM-02-002 |
append-history CLI Tool
| ID | Priority | Requirement | Related |
|---|---|---|---|
HM-03-001 |
MUST | The tool MUST be invokable as uv run append-history <type> where <type> is one of the valid HistoryEntryType values (see HM-02-002). |
|
HM-03-009 |
MUST | Passing an unrecognised <type> to append-history MUST produce a clear error message and exit non-zero. |
Refines: HM-03-001 |
HM-03-002 |
MUST | When no --file argument is given, the tool MUST read entry content from standard input (stdin). This enables pipeline use: echo "..." | uv run append-history idea. |
|
HM-03-003 |
MUST | The tool MUST accept a --file <path> argument as an alternative to stdin. When --file is given, content is read from the specified file rather than stdin. |
|
HM-03-004 |
MUST | The tool MUST determine the target month directory using the current system clock (datetime.date.today()) during normal operation. |
|
HM-03-005 |
MUST | The tool MUST create plan/history/YYMM/ and plan/history/YYMM/<type>/ if they do not already exist before writing the entry file. |
|
HM-03-006 |
MUST | After writing a new entry file, the tool MUST regenerate plan/history/YYMM/README.md by scanning the known-type subdirectories (idea/, implementation/, learning/, priority/), reading each entry file's frontmatter, and writing a summary table sorted by timestamp descending. |
|
HM-03-007 |
MUST | The append-history tool's Python module MUST reside under vultron/metadata/history/ as a sibling of vultron/metadata/specs/. |
|
HM-03-010 |
MUST | The append-history entry point function MUST be vultron.metadata.history.cli:main. |
Refines: HM-03-007 |
HM-03-008 |
MUST | The tool MUST be registered in pyproject.toml under [project.scripts] as append-history = "vultron.metadata.history.cli:main". |
|
HM-03-011 |
MAY | A backfill-only date override MAY exist for migration tooling so legacy entries can be written into historical YYMM directories.Migration tooling requires placing entries into historical month directories; this override is available only for that purpose. |
Refines: HM-03-004 |
Timestamp Field and Future-Date Rejection
| ID | Priority | Requirement | Related |
|---|---|---|---|
HM-06-007 |
MUST | The append-history tool MUST populate the timestamp field automatically from the current system clock when creating new entries. |
Implements: HM-06-001 |
HM-06-002 |
MUST | HistoryEntryFrontmatter MUST require a timestamp field (timezone-aware ISO 8601 datetime in UTC). Validation MUST fail with a clear error if timestamp is absent or malformed. |
|
HM-06-004 |
MUST | HistoryEntryFrontmatter MUST reject any timestamp value that is more than 60 seconds in the future (relative to the current UTC system clock). This tolerance accounts for clock drift between the time the timestamp is computed and when the Pydantic model is validated. |
|
HM-06-008 |
MUST | Naive datetime values (those without a UTC offset) MUST be rejected with a clear error message. | Refines: HM-06-005 |
HM-06-009 |
MUST | All stored and compared timestamps MUST be normalised to UTC. | Refines: HM-06-005 |
HM-06-006 |
MUST | The plan/history/YYMM/README.md index MUST display both a Date column (YYYY-MM-DD extracted from timestamp) and a Time (UTC) column (HH:MM extracted from timestamp). Entries MUST be sorted by timestamp in descending order. |
CLI Interface for Frontmatter Construction
| ID | Priority | Requirement | Related |
|---|---|---|---|
HM-07-001 |
MUST | The append-history <type> command MUST accept --title and --source as required named arguments. |
|
HM-07-005 |
MUST | The tool MUST construct the YAML frontmatter block internally from the --title, --source, and auto-generated timestamp arguments; callers MUST NOT supply frontmatter content. |
Refines: HM-07-001 |
HM-07-002 |
MUST NOT | Callers of append-history MUST NOT include a YAML frontmatter block in the content they supply via stdin or --file. |
Refines: HM-07-005 |
HM-07-003 |
MUST | The tool MUST accept a hidden --timestamp argument for backfill use only (e.g., migrating pre-existing entries). |
|
HM-07-006 |
MUST | The --timestamp override argument MUST accept an ISO 8601 datetime string with a UTC offset. |
Refines: HM-07-003 |
HM-07-007 |
MUST | The standard future-date validation (HM-06-004) MUST apply to values supplied via the --timestamp override argument. |
Refines: HM-07-003 Implements: HM-06-004 |
HM-07-004 |
MUST | Skill documentation (plan-issue, build, learn) and notes/history-management.md MUST use the --title/--source interface for append-history. The frontmatter-in-stdin pattern MUST NOT be used. |
GitHub Comment Output Mode
HTTP Protocol Requirements
HTTP protocol requirements for the Vultron API, covering status codes, headers, and content negotiation.
File ID: HTTP
Request Size Limits
| ID | Priority | Requirement | Related |
|---|---|---|---|
HTTP-02-001 |
MUST | The default maximum inbox request body size MUST be 1 MB. An operating limit of this codebase, which other implementations may set differently. The answer to a body over the limit is HTTP 413, which HTTP itself defines (IE-03-003, HTTP-03-007). |
Refines: IE-03-002 |
HTTP Status Codes
The status codes HTTP itself defines for each outcome, which Vultron inherits (foundation, http). Where HTTP leaves the choice open, as with 422 for a schema failure, the code is this codebase's choice and the spec is project.
Response Headers
| ID | Priority | Requirement | Related |
|---|---|---|---|
HTTP-04-002 |
MUST | Endpoints returning JSON that is not an ActivityStreams document (e.g., health checks, error bodies, the datalayer dump) MUST use Content-Type: application/json.Split from HTTP-04-001 (MS-12-010), which keeps the media type ActivityStreams defines. Which media type this codebase's other JSON responses carry is its own choice. |
Extends: HTTP-04-001 |
Correlation ID Propagation
Request-tracing headers are an operating choice of this codebase's server; other Vultron implementations may trace requests differently, so these specs are project.
Request Timeout Handling
A response-time target and how this server meets it are operating limits of this codebase, not rules every Vultron implementation must obey, so these specs are project.
Rate Limiting Headers
Which rate-limit and retry headers this server sends is this codebase's HTTP choice. HTTP lets a server send Retry-After on a 429 or 503 but does not require it; making it a MUST here binds this server alone, so these specs are project.
FastAPI Response Serialization
| ID | Priority | Requirement | Related |
|---|---|---|---|
HTTP-08-001 |
MUST | API responses MUST include all fields of the actual returned object type, not only fields declared on the annotated base class |
AS2 Response Class Convention
Inbox Endpoint
The inbox endpoint is the primary entry point for actor-to-actor communication in Vultron. It receives ActivityStreams activities via HTTP POST, validates them, and queues them for asynchronous processing.
File ID: IE
Endpoint Configuration
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-02-005 |
MUST | The inbox endpoint MUST serve POST only. It MUST NOT serve the read (GET) that ActivityPub allows an inbox's authenticated owner. Split from IE-02-002 (MS-12-010), which keeps the POST delivery ActivityPub defines. ActivityPub lets the actor that owns an inbox read it with an authenticated GET; this codebase has no authentication, so a GET would open another actor's mail, and it serves none. Another implementation with authentication may serve the owner's read. The answer to any other method is HTTP's 405 (IE-02-003). |
Refines: IE-02-002 Depends on: IE-02-003 — the status that refuses the methods this spec does not serve |
Request Handling
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-03-002 |
MUST | The endpoint MUST enforce a configurable maximum request body size. | |
IE-03-004 |
MUST | The default maximum request body size MUST be documented in the deployment configuration reference. Operators need to know the default limit to tune it for their deployment environment. |
Refines: IE-03-002 |
Activity Validation
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-04-002 |
MUST | The endpoint MUST return HTTP 422 for validation failures | Depends on: HTTP-03-001 |
Response Timing
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-05-001 |
MUST | The endpoint MUST return HTTP 202 within 100ms | Depends on: HTTP-06-001 |
IE-05-002 |
MUST NOT | The endpoint MUST NOT block on handler execution |
Asynchronous Processing
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-06-001 |
MUST | The endpoint MUST queue activities for background processing | |
IE-06-002 |
MUST | Background processing MUST be isolated from HTTP request/response cycle | |
IE-06-003 |
MUST NOT | The inbox background task MUST NOT execute the synchronous BT pipeline (process_payload, including the pending-queue replay loop) on the ASGI event loop. It MUST run that work in a worker thread — as the synchronous trigger routes already do — so that while one activity's handler executes the process keeps accepting deliveries from peers, answering DataLayer and trigger requests, and draining its own outboxes.Starlette runs an async background task on the event loop itself, and the BT pipeline is synchronous (BT ticks, the BT global lock, SQLite I/O). Called inline it stalls the whole container for every inbound activity, so IE-06-002's isolation holds only for the request that scheduled it. The co-hosted OutboxMonitor coroutine then pays one full BT tick per delivery — the CaseActor's Create(VulnerabilityCase) was measured arriving 17 s after it was queued on the vendor container (#3033) — and a trigger route waiting on the BT lock from its threadpool thread starves until the client times out (#2898). The per-actor asyncio lock is held across the thread hop, so BT-11-001's per-actor FIFO is preserved. |
Refines: IE-06-002 Depends on: BT-11-004 |
IE-06-004 |
MUST | The inbox background task (run_inbox_pipeline in vultron/adapters/driving/fastapi/inbox_orchestration.py) MUST catch an exception raised while processing an item it replays from the receiving actor's inbox queue and log it at ERROR with the item's activity_id and actor_id. It MUST drop an item that raised VultronProtocolViolationError as a permanent failure. It MUST re-queue an item that raised any other exception at most a bounded, configurable number of attempts, and MUST then move it to a dead-letter store, using the same persisted retry and dead-letter mechanism as outbox delivery.Handler exceptions propagate to this boundary (DR-03-002). A protocol violation fails the same way every time, so it is dropped. Any other exception may be transient or a deterministic bug, so an unbounded re-queue could loop forever. The outbox's persisted mechanism (OX-13) keeps counts across restarts. A failed act keeps nothing (ADR-0151), so a retry re-runs the whole act. A live delivery is not queued: its retry is the sender's resend (ASK-07-010). |
Depends on: DR-03-002 Refines: IE-06-001 |
IE-06-005 |
MUST | Each attempt to replay an item from the receiving actor's inbox queue MUST commit as one DataLayer transaction: taking the item's id off the queue, releasing the record that holds it for its replay, the attempt itself, and the attempt's settlement (its attempt count, the record held again, the id queued again, its dead letter, and any fault held for its sender). A process stop, or a raise the drain does not catch and settle as a failed attempt (IE-06-004), at any point in the attempt MUST leave the item queued and held, with the attempt count it had before the attempt began, and MUST end that drain with the raise, so the item waits for the next replay. The held record is the only copy of a received activity the replay can rebuild it from (DL-06-006), and the sender is not answered while the item is retried (ASK-07-010), so an item lost between its dequeue and its settlement is never recovered. Writes that must land together share one transaction, never a repair pass (ADR-0146). The act's own transaction nests inside the attempt's, so a failed act still keeps nothing (ADR-0151). The attempt count bounds an activity's failures, not the store's: a raise that escapes the attempt rolls back the count with everything else, so it is not counted but surfaced, and an item whose settlement fails every time is retried by each drain until the store fault is fixed. |
Refines: IE-06-004 Depends on: DL-06-006 |
IE-06-006 |
MUST | When the serving process starts, it MUST replay every item queued in each hosted actor's inbox queue, with the same bounded retry and dead letter as a replay after a delivery (IE-06-004), under the same per-actor lock as that actor's inbox background task and off the ASGI event loop (IE-06-003). Without it, an item that survived a stop (IE-06-005) is replayed only when the next delivery reaches the same actor, which may never happen. The outbox has the same safety net in its monitor (OX-09-002). |
Depends on: IE-06-005 Depends on: IE-06-003 |
Error Responses
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-07-002 |
MUST | The endpoint MUST return HTTP 202 for a successfully accepted activity, HTTP 400 for an activity whose addressing provably excludes the receiving actor (IE-11-001), and HTTP 422 for an activity that fails schema validation. These codes are the reference implementation's choices, not protocol requirements: the protocol does not yet say which HTTP responses a receiving actor returns, so a peer cannot rely on them (see the open question on transport status codes). Split from IE-07-001, which keeps the codes HTTP itself defines (MS-12-010). |
Extends: IE-07-001 |
Response Format
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-08-001 |
MUST | Response body MUST be JSON with fields: status, activity_id, message |
Depends on: EH-05-001 |
Logging
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-09-001 |
MUST | The endpoint MUST log all requests at INFO level | Depends on: SL-01-001 Depends on: SL-02-001 Depends on: SL-03-001 |
Idempotency
| ID | Priority | Requirement | Related |
|---|---|---|---|
IE-10-001 |
SHOULD | The endpoint SHOULD detect a redelivered activity early, at ingress: when the receiving actor already holds the activity under a key it derives from the activity id (its ReceivedActivityRecord, or the DeferredInboxActivity that holds it for replay), and the delivery's sealed body is exactly the body held for that id, the redelivered body is not held again, and the copy the receiver holds is routed in its place (VM-08-002): the deferred record's, without received evidence, while the first delivery is still deferred, else the body intake archived, carrying that body as its received evidence. A delivery under a held id whose body differs is not a redelivery; IE-10-002 refuses it.Ingress is the first point at which the receiver can consult durable evidence of a prior delivery, so the HTTP adapter realises the early filtering ID-05-002 permits there. The evidence is keyed by the receiver: a row under the sender's id reads as one of its own (DL-06-006). Intake's archive is the received body (ADR-0149), so an archived activity's resend is routed as that body's parse, with that body as its evidence, and handler idempotency answers it (ID-04): a committed act is a repeat, and an act that never committed, such as one whose consequence failed, runs as a first delivery and records the archived body (CLP-07-015, ADR-0151). The archive records receipt, not the act, so "already archived" is not "already applied". Only a resend of the same body is a redelivery: routing the held copy in place of a different body dropped that body with nothing but a DEBUG line (#3762). |
Depends on: ID-05-002 Depends on: DL-06-006 |
IE-10-002 |
MUST | The endpoint MUST refuse a delivery whose activity id the receiving actor already holds (IE-10-001) when the delivery's sealed received body (VM-08-002) is not exactly the body held for that id: the deferred record's while the first delivery is still deferred, else the body intake archived. The refused delivery MUST NOT be routed, the held copy MUST NOT be routed in its place, and the endpoint MUST NOT write or change anything in the receiving actor's store for it, so the first delivery's record stays as it was. The refusal MUST be logged at WARNING with the activity id and the sending actor. This applies to every activity type, including an Announce(CaseLedgerEntry) that ingress hydrates in place (SYNC-13-004); a repeat of its held body is still answered by the already-stored gate (SYNC-12-003).An activity id names one activity (ID-01-001): a second body under a held id is a sender bug or a rewrite of what was said, and routing the held copy instead hid it (#3762). Maintainer decisions (2026-10-21, #3762): refuse, do not quarantine, so the WARNING is the refused body's only trace; compare the sealed texts exactly (a SHA-256 digest of each). The inbox answers 202 (IE-07-002) before processing, so the refusal is the inbox outcome. |
Refines: IE-10-001 Depends on: VM-08-002 Depends on: ID-01-001 Refines: ID-02-001 Depends on: SYNC-12-003 |
Inbox Orchestration
Testable requirements for the core BT-backed inbox orchestration module, covering the InboxOutcome model, BT sequence invariants, adapter injection rules, caller interface constraints, and error/outcome behavior.
File ID: IO
InboxOutcome Model
| ID | Priority | Requirement | Related |
|---|---|---|---|
IO-01-001 |
MUST | The inbox orchestration module MUST define an InboxOutcome Pydantic model as the sole return type of process_payload. A typed return model makes outcome inspection safe and self-documenting for all callers (FastAPI, CLI, tests). |
|
IO-01-002 |
MUST | InboxOutcome MUST include a status field typed as the InboxOutcomeStatus StrEnum, whose members are processed, deferred, and rejected. When dispatch ran, the status MUST be derived from the handler's HandlerResult disposition (UCORG-05-011): APPLIED and SKIPPED yield processed, DEFERRED yields deferred, and REFUSED yields rejected. It MUST NOT be inferred from dispatch merely not raising. Three statuses cover the outcome space: dispatched, held for replay, and not processable. Deferred has two producers — the pre-dispatch defer check (case not yet known) and a handler returning DEFERRED — and both keep working. The mapping lives only in the pipeline's dispatch node, so handlers never name this vocabulary (HP-01-004). A dispatch where no handler ran is never processed (UCORG-05-012). |
Depends on: UCORG-05-011 |
IO-01-003 |
MUST | InboxOutcome MUST include a context_id field (nullable string) carrying the case or activity context identifier, when available. Callers need context identity for logging, telemetry, and pending-queue management. |
|
IO-01-004 |
MUST | InboxOutcome MUST include a failure_reason field (nullable string) carrying a human-readable explanation when status is rejected or deferred. When a handler returns REFUSED, failure_reason MUST be the handler's HandlerResult reason. Explicit failure reasons support triage without log scraping. A REFUSED HandlerResult always carries a reason (ADR-0095), so a handler refusal is always explained. |
|
IO-01-005 |
MUST | InboxOutcome MUST include an unhandled field (boolean, default false) that is true only for a rejected outcome whose semantic type no use case handles ("no use case found", SE-05-002), and the driving adapter MUST log such an outcome at INFO rather than at the WARNING it uses for other rejections (UCORG-05-013).A receiver that does not handle a message has not found the message invalid (ADR-0140 item 5), so a recognized-but-unhandled activity is a normal operation, not a recoverable anomaly (SL-03-001). The flag is set from the dispatcher's own verdict, not read from the reason text. |
Refines: UCORG-05-013 Depends on: SE-05-002 |
BT Orchestration Module
| ID | Priority | Requirement | Related |
|---|---|---|---|
IO-02-001 |
MUST | The inbox orchestration module MUST be located at vultron/core/behaviors/inbox/.Placement in core/ satisfies the hexagonal architecture constraint that core imports no framework (ARCH-01-001, ADR-0009) and enables the module to be used by FastAPI, CLI, and test adapters equally. |
|
IO-02-001b |
MUST NOT | The inbox orchestration module MUST NOT import from vultron/adapters/ or any web framework package. |
Refines: IO-02-001 |
IO-02-002 |
MUST | The BT implementation MUST enforce the following fixed node ordering via BT Sequence nodes: (1) parse inbound payload, (2) rehydrate Activity, (3) extract MessageSemantics, (4) defer-eligibility check, (5) dispatch to use-case path, (6) build typed outcome. Fixed BT ordering makes the inbound pipeline auditable and prevents callers from accidentally invoking steps out of order. |
|
IO-02-003 |
MUST | The process_payload function MUST be the sole caller-facing entry point of the inbox orchestration module.Encapsulating intermediate steps preserves depth and prevents shallow reimplementations of parts of the pipeline. |
|
IO-02-003b |
MUST NOT | Intermediate behavior-tree nodes used internally by process_payload MUST NOT be exported as public API symbols. |
Refines: IO-02-003 |
IO-02-004 |
MUST NOT | The inbox orchestration module MUST NOT instantiate or call a use-case class directly inside a behavior-tree node update() method.Use cases create BTs; BT nodes are leaves. Calling a use case from a node creates a prohibited BT-UseCase-BT chain (BT-06-006). |
Adapter Injection
| ID | Priority | Requirement | Related |
|---|---|---|---|
IO-03-001 |
MUST | process_payload MUST accept exactly two injected adapters: an ingress payload adapter (raw input to as_Activity) and a dispatch adapter (VultronEvent to use-case execution). The dispatch adapter MUST return the handler's HandlerResult (UCORG-05-010), which is the only channel by which the verdict reaches InboxOutcome. Two well-named adapters keep the seam explicit and prevent the adapter matrix from growing large enough to make the module shallow again. Returning the HandlerResult through the adapter, rather than through a blackboard key a handler would have to write, keeps InboxOutcome's vocabulary out of handlers (HP-01-004). |
Depends on: UCORG-05-010 |
IO-03-002 |
MUST | Pending-queue management MUST be provided to the inbox orchestration module via an injected port dependency. Injecting the pending-queue port keeps core/ free of adapter dependencies and aligns with hexagonal architecture. |
|
IO-03-002b |
MUST NOT | The inbox orchestration module MUST NOT access pending-queue storage directly from vultron/adapters/. |
Refines: IO-03-002 |
IO-03-003 |
MUST | The FastAPI inbox path MUST supply adapter implementations to process_payload at construction time.Policy logic in the router binds it to the web framework, against the hexagonal layering that keeps policy in a framework-free core (ARCH-01-001, ADR-0009), and makes the orchestration untestable without a running FastAPI app. |
|
IO-03-003b |
MUST NOT | The FastAPI inbox path MUST NOT contain inbox policy logic; all such logic MUST reside in the core inbox orchestration module. Exception: a synchronous guard that refuses an inbound Activity before the HTTP 202 is returned (and before the background task is scheduled) MAY live at the route level when synchronous refusal is required — see IE-11-001 and ADR-0068. Policy in the router binds it to the web framework, against the hexagonal layering that keeps policy in a framework-free core (ARCH-01-001, ADR-0009), and makes the orchestration untestable without a running FastAPI app. The narrow exception for synchronous pre-202 guards exists because such guards must act before the background task is scheduled, which requires route-level placement. ADR-0068 documents this tradeoff. |
Refines: IO-03-003 |
IO-03-004 |
MUST NOT | The ingress payload adapter MUST NOT write an object inlined in a received activity to the receiving actor's DataLayer as a record readable by that object's id, on receipt or for a deferred replay. To rebuild the routed activity it MAY stage the inline objects in a holding area outside that DataLayer that answers a read only when the DataLayer holds nothing under the id and keeps nothing past the delivery. For an activity the inbox defers, it MAY keep the activity itself, as the received body the parser sealed (VM-08-002), beside the staged rows in one opaque record keyed by the activity, deleted by the replay or by the queue's expiry. A held out-of-order Announce(CaseLedgerEntry) (SYNC-14-001) is kept in the same record, deleted by its replay or by the holding area's bound (SYNC-14-006). A handler therefore sees the receiver's record of any id it holds, never the carried copy, so a guard keys on the stored record (HP-08-006).A stored inline object is the receiver's own record of it: a stored case is the receiver's replica, so storing one at receipt let a sender's snapshot name the sender as CASE_MANAGER and pass the trust check that should refuse it (PCR-03-004, CBT-01-011). Routing still needs the inline object, because the rebuilt activity names it by id and a bare id matches the wrong pattern. The deferred record holds the activity too: no copy under its sender's id remains to replay (DL-06-006). The receiver's record wins on purpose; a carried case winning broke rebuild-from-ledger (#4831). |
Refines: CLP-10-017 |
Caller Interface
| ID | Priority | Requirement | Related |
|---|---|---|---|
IO-04-001 |
MUST | process_payload MUST always return an InboxOutcome instance, regardless of payload validity.Typed outcomes allow callers to handle all cases uniformly without exception-driven control flow at the integration boundary. |
|
IO-04-001b |
MUST NOT | process_payload MUST NOT raise an exception for protocol-invalid payloads; all error conditions MUST be encoded in the returned InboxOutcome. |
Refines: IO-04-001 |
IO-04-002 |
MUST | Tests for process_payload MUST assert on InboxOutcome.status and InboxOutcome.failure_reason fields.Interface-level test assertions are resilient to internal refactors and validate the contract rather than the implementation. |
|
IO-04-002b |
MUST NOT | Tests for process_payload MUST NOT patch internal behavior-tree node helper methods to verify pipeline behavior. |
Refines: IO-04-002 |
IO-04-003 |
SHOULD | Both a production ASGI adapter and an in-memory test adapter SHOULD implement the ingress payload adapter interface, so that the same process_payload seam is used in both runtime and test contexts. Shared interface across runtime and test adapters validates real-world behavior and reduces adapter-specific test scaffolding. |
Inbox Pipeline
Requirements for InboxPipeline, an injectable class that exposes the full rehydrate, prepare-for-dispatch, and dispatch-or-defer chain as a single testable unit. Honours the existing ActivityDispatcher and DataLayer ports so that tests can drive the real pipeline with in-memory adapters.
File ID: IBP
InboxPipeline Class Contract
| ID | Priority | Requirement | Related |
|---|---|---|---|
IBP-01-001 |
MUST | InboxPipeline MUST accept an ActivityDispatcher and a DataLayer as constructor parameters and store them as private attributes.Injection via constructor makes the pipeline testable with any compliant adapter pair — including in-memory test doubles — without monkeypatching module-level globals. |
|
IBP-01-002 |
MUST | InboxPipeline.process(activity_id: str) MUST execute the full inbox processing chain: rehydrate the activity from the DataLayer, extract the domain event, check case context, defer to the wait queue or dispatch via the activity dispatcher, and handle errors with requeue logic.Wrapping the full rehydrate-to-dispatch path (not the simpler handle_inbox_item) ensures that deferral and error-requeue behaviours are exercisable in unit tests. |
Depends on: DR-01-001 |
IBP-01-003 |
MUST | InboxPipeline.process() MUST return the VultronEvent when the activity was dispatched, and MUST return None when the activity was deferred or an error prevented dispatch.A None return is an unambiguous signal to routing-safety-net tests that dispatch did not occur, without requiring the test to inspect DataLayer side-effects. |
|
IBP-01-004 |
MUST NOT | InboxPipeline MUST NOT replace or modify the module-level _DISPATCHER_SLOT or the init_dispatcher() / dispatch() functions in inbox_handler.py.The pipeline is a parallel path; the existing lifespan wiring remains the canonical production entry point. |
Factory Functions
| ID | Priority | Requirement | Related |
|---|---|---|---|
IBP-02-001 |
MUST | A build_test_pipeline(dl: DataLayer) -> InboxPipeline module-level factory function MUST be provided in the same module as InboxPipeline.A named factory hides DirectActivityDispatcher construction, use_case_map() lookup, and port-factory wiring so that tests remain focused on behaviour rather than plumbing. |
|
IBP-02-002 |
MUST | build_test_pipeline() MUST construct a DirectActivityDispatcher with the real use_case_map() and the same port factories that make_dispatcher() uses in production.Using production wiring guarantees that routing-safety-net tests fail immediately when a SEMANTIC_REGISTRY use-case registration is missing or a use-case constructor signature drifts. |
Depends on: DR-02-001 |
IBP-02-003 |
SHOULD | A pytest fixture named test_pipeline SHOULD be provided in a conftest.py file in the test directory that exercises InboxPipeline, yielding a (InboxPipeline, DataLayer) tuple backed by an in-memory SqliteDataLayer("sqlite:///:memory:").A shared fixture eliminates per-test boilerplate and ensures every routing-safety-net test starts with an identical, isolated DataLayer. |
Module Location
Routing-Safety-Net Test Coverage
| ID | Priority | Requirement | Related |
|---|---|---|---|
IBP-04-001 |
MUST | At least one routing-safety-net test MUST exist per semantic domain (report, case, embargo, note, actor, status, sync) asserting that the correct use case fires and produces the expected DataLayer side effect. Per-domain coverage catches SEMANTIC_REGISTRY use-case registration gaps immediately without requiring exhaustive per-enum-value tests. |
Depends on: DR-02-002 Depends on: TB-02-002 |
IBP-04-002 |
MUST | A routing-safety-net test MUST use build_test_pipeline() (real wiring) and MUST NOT mock _DISPATCHER_SLOT, prepare_for_dispatch, or use_case_map().Mocking the exact symbols the test is supposed to exercise defeats the purpose of a routing safety net. |
|
IBP-04-003 |
MUST | Tests that exercise the deferral path MUST assert that process() returns None and that the activity ID appears in the pending-case inbox after the call. |
|
IBP-04-004 |
SHOULD | Routing-safety-net tests SHOULD be placed in test/adapters/driving/fastapi/test_inbox_pipeline.py and SHOULD NOT require the @pytest.mark.integration marker, since they use only in-memory adapters.Keeping these tests in the unit suite ensures they run on every local pytest invocation, providing fast feedback without needing the full integration environment. |
Depends on: TB-04-001 |
Lifecycle-Staged Domain Types
Rules for lifecycle-staged domain types anchored on guaranteed-field changes. A lifecycle milestone earns a distinct staged type only when it changes the set of required (non-None) fields; milestones that only gate permitted actions are modeled as predicates and preconditions rather than new types.
File ID: LST
Field-Set Governing Principle
VulnerabilityCase Staged Types
No Subtypes for Status Objects
Ratchets as Predicates and State Groups
Read-Boundary Promotion and Transition Model
| ID | Priority | Requirement | Related |
|---|---|---|---|
LST-05-001 |
MUST | A core function requiring a narrowed staged type MUST obtain it by validating the generic object at its entry boundary (e.g. EmbargoedCase.model_validate(case)), which narrows on success and raises immediately on invariant violation.Applies ADR-0032's validate-at-the-edge pattern at the DataLayer read boundary; the subtype's own validator is the promotion logic. |
Depends on: ARCH-15-002 |
LST-05-002 |
MUST NOT | The lifecycle stage MUST NOT be stored as a persisted field on any domain object. A stored stage can drift from the underlying invariants, creating two sources of truth. See ADR-0033 transition model. |
Protocol Shorthand to MessageSemantics Mapping
Authoritative normative bridge from protocol message shorthand labels (RS, EP, CV, etc.) used in behavioral conformance specs to their MessageSemantics enum values and corresponding AS2 wire-format specifications. Each entry provides one row in the three-hop chain: shorthand to MessageSemantics enum value to AS2 wire form. The correspondence is many-to-many in both directions: several shorthands may collapse onto one wire activity (CV/CF onto vfState, CP/CX/CA onto pxaState, EV/EJ/EC onto EP/ER/EA), and one shorthand may expand across several wire activities (GI). Shorthands with no dedicated dispatch value are the protocol-layer error and acknowledgment signals; these are not unimplemented — the purposes they name are served by mechanisms partitioned on a different axis, specified normatively in MSM-05.
File ID: MSM
RM Protocol Message Shorthands
Normative shorthand-to-semantic mappings for Report Management (RM) protocol messages, covering RS, RI, RV, RD, RA, RC, RE, and RK.
| ID | Priority | Requirement | Related |
|---|---|---|---|
MSM-01-007 |
MUST | Protocol shorthand RE (Report Management Error) has no dedicated MessageSemantics dispatch value and no entry in SEMANTIC_REGISTRY; report-management error conditions MUST instead be reported through the failure-mode mechanisms specified in MSM-05-001.RE names an error condition scoped to report management. The implementation does not partition faults by state machine, so there is no RE-shaped wire form; it partitions by failure mode instead — not understood, declined, or needing explanation (MSM-05-001). Read this entry as "mapped onto a different axis", not as "unimplemented". |
Refines: MSM-05-001 |
EM Protocol Message Shorthands
Normative shorthand-to-semantic mappings for Embargo Management (EM) protocol messages, covering EP, EA, EV, EJ, EC, ER, ET, EE, and EK. Embargo revision shorthands (EV, EJ, EC) share wire formats with their initial-proposal counterparts (EP, ER, EA); the distinction is contextual rather than structural.
| ID | Priority | Requirement | Related |
|---|---|---|---|
MSM-02-003 |
MUST | Protocol shorthand EV (Embargo Revision Proposed) MUST dispatch as MessageSemantics.INVITE_TO_EMBARGO_ON_CASE; its AS2 wire form is Invite(Event)[context=VulnerabilityCase] per VAM-05-005 — identical to EP. The revision context is inferred from the presence of an active embargo.EV, EJ, and EC are aliases for EP, ER, and EA at the wire layer ( vultron/bt/messaging/states.py). The distinction between initial proposal and revision proposal is not encoded in the AS2 activity structure; implementations MUST infer it from local EM state. |
Derives from: VAM-05-005 |
MSM-02-008 |
MUST | Protocol shorthand EE (Embargo Management Error) has no dedicated MessageSemantics dispatch value and no entry in SEMANTIC_REGISTRY; embargo-management error conditions MUST instead be reported through the failure-mode mechanisms specified in MSM-05-001.Symmetric with MSM-01-007 ( RE): faults are partitioned by failure mode rather than by state machine, so no embargo-scoped error wire form exists. Tests SHOULD assert that EE is absent from MessageSemantics and the semantic registry to catch unintended additions. |
Refines: MSM-05-001 |
MSM-02-009 |
MUST | Protocol shorthand EK (Embargo Management Acknowledgment) has no dedicated MessageSemantics dispatch value and no entry in SEMANTIC_REGISTRY; acknowledgement of embargo state carried on the case ledger MUST instead follow the cumulative hash-chain mechanism specified in MSM-05-002.Per-message positive acknowledgement is redundant for ledger-replicated state: a matching prev_log_hash already proves receipt of every prior entry (MSM-05-002). Tests SHOULD assert that EK is absent from MessageSemantics and the semantic registry. |
Refines: MSM-05-002 |
CS Protocol Message Shorthands
Normative shorthand-to-semantic mappings for CVD Case State (CS) protocol messages, covering CV, CF, CD, CP, CX, CA, CE, and CK. CS status shorthands are split across two semantics by dimension scope, because the CS hypercube mixes participant-scoped and case-scoped dimensions: the participant-scoped V/F/D dimensions (CV, CF, CD) dispatch as ADD_PARTICIPANT_STATUS_TO_PARTICIPANT and are encoded in the ParticipantStatus payload, while the case-scoped P/X/A dimensions (CP, CX, CA) dispatch as ADD_CASE_STATUS_TO_CASE and are encoded in the CaseStatus payload. There are no case-level V/F/D states (ADR-0075). In both groups the specific state transition is encoded in the object payload rather than in the activity type.
| ID | Priority | Requirement | Related |
|---|---|---|---|
MSM-03-007 |
MUST | Protocol shorthand CE (Case State Error) has no dedicated MessageSemantics dispatch value and no entry in SEMANTIC_REGISTRY; case-state error conditions MUST instead be reported through the failure-mode mechanisms specified in MSM-05-001.Symmetric with MSM-01-007 ( RE): faults are partitioned by failure mode rather than by state machine, so no case-state-scoped error wire form exists. Tests SHOULD assert that CE is absent from MessageSemantics and the semantic registry. |
Refines: MSM-05-001 |
MSM-03-008 |
MUST | Protocol shorthand CK (Case State Acknowledgment) has no dedicated MessageSemantics dispatch value and no entry in SEMANTIC_REGISTRY; acknowledgement of case-state changes carried on the case ledger MUST instead follow the cumulative hash-chain mechanism specified in MSM-05-002.Symmetric with MSM-02-009 ( EK): case-state changes are ledger-replicated, so hash-chain continuity already acknowledges them cumulatively and a per-message ack would be redundant. Tests SHOULD assert that CK is absent from MessageSemantics and the semantic registry. |
Refines: MSM-05-002 |
General Protocol Message Shorthands
Normative shorthand-to-semantic mappings for General (non-state-machine) protocol messages, covering GI, GK, and GE. Unlike the RM, EM, and CS groups, the General shorthands map to wire activities by expansion: a single shorthand covers several distinct wire activities rather than collapsing several shorthands into one.
| ID | Priority | Requirement | Related |
|---|---|---|---|
MSM-04-001 |
MUST | The note lifecycle MUST realize protocol shorthand GI (General Inquiry) as MessageSemantics.ADD_NOTE_TO_CASE and MessageSemantics.REMOVE_NOTE_FROM_CASE; their AS2 wire forms are Add(Note)[target=VulnerabilityCase] per VAM-07-002 and Remove(Note)[target=VulnerabilityCase] per VAM-07-003. A received Create(Note) is not a protocol message (VAM-01-011). These are not the whole of GI — MSM-04-002 adds the actor-suggestion exchange.GI is defined in the formal protocol as any message conveying non-state-change information, so it is inherently one-to-many on the wire. The Add and Remove split is a wire-layer artifact with no formal counterpart: the formal set models the inquiry, not the object lifecycle that carries it. |
Derives from: VAM-01-011 Derives from: VAM-07-002 Derives from: VAM-07-003 |
MSM-04-002 |
MUST | Protocol shorthand GI MUST additionally cover the actor-suggestion exchange, dispatching as MessageSemantics.OFFER_ACTOR_TO_CASE, MessageSemantics.OFFER_CASE_PARTICIPANT, MessageSemantics.ACCEPT_OFFER_CASE_PARTICIPANT, MessageSemantics.REJECT_OFFER_CASE_PARTICIPANT, MessageSemantics.ACCEPT_ACTOR_RECOMMENDATION, or MessageSemantics.REJECT_ACTOR_RECOMMENDATION.messages.md lists "suggesting a potential Participant to be added to a case" among its GI examples, so actor suggestion is a GI expansion and MUST NOT be classified as case management. What the formal set models as one inquiry the implementation realizes as a multi-hop negotiation with its own accept and reject responses. |
Derives from: VAM-04-001 Derives from: VAM-04-010 Derives from: VAM-04-002 Derives from: VAM-04-003 |
MSM-04-003 |
MUST | Protocol shorthands GK (General Acknowledgement) and GE (General Error) MUST NOT be assigned dedicated MessageSemantics dispatch values; the purposes they name are served by the mechanisms specified in MSM-05.Recording these as plain absences invites the reading that the behaviour is unimplemented. It is implemented, on a different axis — see MSM-05-001 and MSM-05-002. |
Refines: MSM-05-001 Refines: MSM-05-002 |
Participant Embargo Consent (PEC) State Machine Coupling to EM Wire Activities
Normative record of how each EM wire activity drives the per-participant, per-embargo Participant Embargo Consent (PEC) rows as a side-effect, and which consent positions are derived rather than written. PEC is participant-local state tracking each actor's relationship to each embargo in the case's embargo register; its transitions are not independent protocol messages and have no formal shorthands in the 28-shorthand set. A participant's consent to an embargo is one row holding UNINVITED, INVITED, AGREED, DECLINED or TIMED_OUT; whether the participant is bound by the current active embargo is a lookup of the active embargo's row, and a lapse is read from the rows and the register when a longer embargo replaces the one the participant agreed to. Unlike RM state (self-reported by the participant) and VF/D state (self-reported by vendor/deployer), consent rows are set by the CASE_MANAGER (CM-28-003) based on observed participant behavior — incoming Accept/Reject wire activities and timer-enforced invitation expiry — and replayed on each replica. Normative authority: specs/case-management.yaml CM-18. Decisions: ADR-0048, ADR-0091, ADR-0118, ADR-0122.
| ID | Priority | Requirement | Related |
|---|---|---|---|
MSM-07-008 |
MUST | The wire activity ACTIVATE_EMBARGO_ON_CASE (wire form: Accept(Event)[target=VulnerabilityCase], the EmbargoEvent itself as the object) is the case owner's decision for the case and MUST be accepted only from the case owner; any other sender MUST be refused with REFUSED before any write (HP-01-006). It MUST activate the named proposal's embargo register entry and supersede any entry in force in the same step, so the derived EM becomes ACTIVE (EA, or EC for a revision); MUST mark the owner's row for the activated embargo AGREED unless it already is, a DECLINED row included, since activating is the owner changing its answer (CM-18-003); and, when it replaces an embargo in force, MUST apply the MSM-07-005 consent rule. It MUST be refused before it is committed when the named embargo is not an open proposal of the case, or when CS is public, exploited or attacked (EMB-02-002). Giving the owner's decision its own activity retires the two-audience rule: an Accept of an Invite is always the sender's consent (MSM-07-003), and this activity is always the owner's decision for the case, so neither meaning depends on who sent the message (ADR-0122). Refusing a doomed activation before the commit keeps the ledger free of an entry that replicas, copying its step, would apply without the CASE_MANAGER's P/X/A check (CM-23-016). |
Depends on: VAM-05-008 Derives from: EP-09-005 Depends on: MSM-07-005 |
MSM-07-009 |
MUST | The wire activity REJECT_EMBARGO_PROPOSAL_ON_CASE (wire form: Reject(Event)[target=VulnerabilityCase], the EmbargoEvent itself as the object) is the case owner's decision for the case and MUST be accepted only from the case owner; any other sender MUST be refused with REFUSED before any write (HP-01-006). It MUST reject the named proposal's embargo register entry, so the derived EM becomes NONE (ER) or stays ACTIVE with the prior terms in force (EJ) once no other proposal is open, and stays PROPOSED or REVISE while another is (EP-08-001); it MUST NOT change any participant's consent rows, the owner's included. When it rejects the last open revision while CS is public, exploited or attacked, the embargo MUST also be terminated in the same act (EMB-04-002): the CASE_MANAGER decides both steps together, trial-applying both before committing either, and commits the Reject's entry carrying the REJECT step followed by its own ET Remove(EmbargoEvent) entry carrying the TERMINATE step, so the revision ends REJECTED and EM EXITED, and a refused termination commits neither. It MUST be refused before it is committed when the named embargo is not an open proposal of the case.The owner's rejection decides the proposal for the case; whether the owner, as a participant, also refuses those terms is a separate Reject of the Invite (MSM-07-004). Keeping the two apart lets the owner keep the prior terms without declining them (ADR-0122). |
Depends on: VAM-05-009 Derives from: EP-09-005 Depends on: EMB-04-002 |
MSM-07-010 |
MUST | The wire activity Leave(EmbargoEvent)[context=VulnerabilityCase] is a participant's withdrawal of its consent to the case's active embargo. For a sender whose row for that embargo is AGREED, the receiver MUST apply the PEC DECLINE trigger to that row and MUST also decline every open proposal's row the sender had AGREED to: every open proposal is a revision of the embargo withdrawn from (EP-08-004), and a DECLINED participant MUST NOT hold an AGREED row for an embargo that can become active without re-invitation. The receiver MUST refuse with a reported reason a Leave whose embargo is not the active one, whose sender's row for it is not AGREED, or whose sender is the case owner, which ends an embargo by terminating it (CM-02-005). A Leave changes no register entry.Accept and Reject answer an Invite; leaving an embargo one is bound by is a different act, as Leave(VulnerabilityCase) is for leaving the case. A dedicated activity covers every signatory, including those made signatories with no Invite (CM-14-005, CM-11-001), and keeps a Reject from meaning refusal or withdrawal by the state of the case. The rejected alternatives are recorded in ADR-0122. |
Depends on: VAM-05-011 Derives from: CM-18-003 Refines: VP-13-017 |
Message Validation
Requirements for validating ActivityStreams 2.0 activities at the inbox handler boundary, covering ActivityStreams structure and semantic validation. Invalid activities are rejected with appropriate error responses before dispatch.
File ID: MV
Activity Structure Validation
| ID | Priority | Requirement | Related |
|---|---|---|---|
MV-01-005 |
MUST | The semantic-dispatch stage — the type lookup that runs after parse_activity has already accepted the activity against the AS2 vocabulary — MUST handle missing or null fields without raising unhandled exceptions. |
|
MV-01-006 |
MUST | The semantic-dispatch stage MUST treat an activity whose type matches no entry in the semantic pattern registry (SEMANTIC_REGISTRY, consulted by find_matching_semantics) as resolving to MessageSemantics.UNKNOWN. This is distinct from vocabulary-level resolution: an activity whose type is absent from the AS2 vocabulary is rejected at parse (MV-01-001) and never reaches this stage.Two registries look the activity up on the same value, so a bare "unrecognized type" is ambiguous between them. Reject-at-parse and route-to- UNKNOWN are both correct answers to differently-scoped questions; naming the registry is what disambiguates. Routing an unmatched semantic pattern to UNKNOWN lets the system log and continue rather than crashing the dispatch pipeline (ISSUE-3217). |
Refines: MV-01-005 |
MV-01-007 |
MUST NOT | The semantic-dispatch stage MUST NOT propagate internal dispatch errors to callers. Propagating internal errors exposes implementation details to senders and can cause the inbox handler to return unexpected error responses. |
Refines: MV-01-005 |
Schema Validation
| ID | Priority | Requirement | Related |
|---|---|---|---|
MV-02-001 |
MUST | The system MUST use Pydantic models to validate activities | Implements: VP-15-001 |
MV-02-002 |
MUST | The system MUST reject activities that fail Pydantic validation with HTTP 422 | |
MV-02-003 |
MUST | Validation error responses MUST include detailed error information | |
MV-02-004 |
MUST | The system MUST log validation failures at WARNING level |
Object Type Validation
| ID | Priority | Requirement | Related |
|---|---|---|---|
MV-04-003 |
MUST NOT | When an inline object's type resolves to a known wire vocabulary class, a receiver MUST NOT substitute a less specific type for it if validation against that class fails. The receiver MUST reject the enclosing activity as malformed instead. Resolution of an inline object's type MUST consider only wire-branch classes; an unresolved type leaves the object for its parent field to validate.Substituting a weaker type is silent data loss: an Accept(Invite(...)) with one corrupt inner field parsed clean as a bare as_Link, extracted as UNKNOWN, and drew a 202. Wire-only resolution makes refusal safe: while the CORE_TYPE_MAP fallback (ARCH-12-003) was the lookup's default, an inline actor's inbox resolved to a core instance its wire parent rejects (ARCH-22-001). The fallback is now opt-in (VM-06-008). |
Refines: MV-04-001 |
Outbound Activity Object Integrity
Stub Objects
Inbound Unknown-Key Disposition
| ID | Priority | Requirement | Related |
|---|---|---|---|
MV-11-001 |
MUST | The parse edge (parse_activity in vultron/wire/as2/parser.py) MUST decide what happens to an unrecognised key on inbound data for every object at every depth — the activity envelope, an inline object whose type resolves to a wire class, an inline object whose type resolves to a core class, an inline Link, and an inline object with no type — by partitioning the arriving keys against the resolved class's declared spellings before validating it. A declared spelling is a field name, the camelCase alias the class generates for it, an explicit validation alias, or @context. The disposition MUST NOT depend on which branch the resolved class belongs to.Measured on 2026-09-30, an unknown key was refused with a 422 when the inline object happened to be a domain type (a core class carrying extra="forbid", ARCH-12-003) and dropped silently when it landed on the envelope, a generic AS2 object, a Link, or an untyped inline dict (wire classes inheriting Pydantic's default extra="ignore"). Neither outcome was chosen for inbound data; both were inherited from a configuration whose purpose is elsewhere. Under one object model (ADR-0099) the parser is the inbound wire-to-core seam, so it is the one place a receive-side rule can be stated once and hold everywhere (ADR-0032: validate at the edge; ARCH-21-002: inbound wire data stays lenient). |
Depends on: MV-04-003 — MV-04-003 decides which class an inline object resolves to; this requirement decides what happens to the keys that class does not declare. |
MV-11-002 |
MUST | A receiver MUST refuse the whole activity, with the schema-fault response MV-02-002 requires, when an unrecognised inbound key is a near miss for a declared spelling of the resolved class or matches a retired name. A key is a near miss when lowercasing it and removing every non-alphanumeric character yields the same string as a declared spelling treated the same way (so Actor, attributed-to and ATTRIBUTEDTO are near misses of actor and attributedTo). A spelling the resolved class accepts on input is declared, never a near miss: both roots validate by field name as well as by alias (CS-14-001, CS-14-002), so the snake_case field name attributed_to is a declared spelling of attributedTo. The JSON-LD keywords @id and @type are near misses of id and type by design, not an accident of the normalisation: AS2 spells these fields id and type with no @ prefix, because the normative AS2 context aliases the keywords and AS2 Core section 2.1 requires the serialized form to be consistent with compaction under that context, so a document carrying @id or @type is legal JSON-LD but not a conforming AS2 document (MV-01-001). @context is the only JSON-LD keyword that is a declared spelling; a receiver MUST NOT declare @id or @type as an alias or exempt them from the near-miss test. The refusal MUST name the arriving key and the declared or retired spelling it resembles. The near-miss test MUST be exactly this normalisation plus the retired-name list: edit distance, prefix matching, or any other similarity measure MUST NOT be used.A near miss is a key the sender meant us to read. Proceeding without it loses their data with a log line attached, which is the #2232 shape; refusing tells the sender which field to fix. The normalisation is deliberately shallow: AS2 has many two- and three-letter property names within one character of each other, so an edit-distance model would misfire constantly, and pinning down a richer notion of "near" is not worth the time (ARCH-25-008; ADR-0099). A retired name is not a spelling of anything, so it is in the list by name rather than by normalisation. The JSON-LD keywords are named so the refusal is understood as a decision: a peer that emits @id/@type skipped compaction or compacted under a context without the AS2 aliases, and it meant the id/type Vultron reads. Setting the keywords aside would strip the object's identity and type and cascade into other refusals; declaring them as aliases would make Vultron a partial JSON-LD processor with no principled place to stop (full-IRI property names and @value objects are equally legal JSON-LD). Refusing tells the peer the compact spelling to send (CONCERN-3958). |
Refines: MV-11-001 Depends on: MV-02-002 Depends on: MV-01-001 |
MV-11-003 |
MUST | An unrecognised inbound key that is not a near miss and not a retired name MUST be set aside and reported, not refused: the activity proceeds on its declared fields, and the receiver logs one INFO record per set-aside key carrying activity_id, the sender's actor_id, the dotted field path of the object the key sat on, and the key itself. A set-aside key MUST NOT be carried into the parsed object or appear in any dump of it; the raw body remains available as received evidence (VM-08-002).AS2 is designed to be extended, and refusing a peer's whole message for one property Vultron does not read is a federation cost with no benefit to the protocol. Silence is not acceptable either: an operator needs to see what peers send that Vultron ignores, and a spelling defect on Vultron's own side that escapes the near-miss net shows up here first. Carrying the key on the object would leak it into the stored form, which extra="forbid" refuses (the cross-class revalidation break of ISSUE-3489), so the report is the only trace and the received evidence is the only copy. |
Refines: MV-11-001 Depends on: SL-02-001 Depends on: SL-02-002 |
Meta-Specification Style Guide
Normative rules for authoring specification files in this project. Covers file structure, requirement format, IDs, writing rules, cross-references, lifecycle, quality criteria, and the delineation between spec files and ADRs.
File ID: MS
ADR Status as Confidence Signal
| ID | Priority | Requirement | Related |
|---|---|---|---|
MS-14-005 |
MUST | ADR frontmatter MUST validate against the AdrFrontmatter Pydantic model (vultron/metadata/adr/schema.py), and a retired ADR MUST carry a superseded_by pointer that resolves to a real ADR file. Enforced by the loader (vultron/metadata/adr/loader.py) via pytest and pre-commit, mirroring the notes frontmatter registry (NF-03, NF-04). ADRs are the confidence signal agents trust as ground truth, yet were the only structured-metadata artifact without a schema — validated only by regex bolt-ons in spec-lint. A Pydantic model makes status typechecking, the superseded_by-when-retired rule, and dangling-target detection real validation rather than string-scraping. See ADR-0043. |
|
MS-14-006 |
MUST | docs/adr/index.md MUST be generated from ADR frontmatter (uv run adr-index --write), and an ADR, archived or not, MUST NOT be referenced in the mkdocs nav: each MUST instead be matched by the not_in_nav patterns in mkdocs.yml and reached through the index. CI (uv run adr-index --check) fails on drift. The index was a hand-maintained list that rotted: ADR-0027 was filed under the wrong index section. Generating it makes that drift mechanically impossible to merge. The nav rule was once the opposite, requiring every non-archived ADR in the nav, but decision records are project working record, which stays out of the reader-facing nav (DF-11-003), and the generated index is their routing page (DF-11-006). An ADR in neither the nav nor not_in_nav breaks mkdocs --strict on an omitted-file warning, so the check requires the pattern match too. See ADR-0043 and MS-14-003. |
|
MS-14-011 |
MUST | ADR supersession links MUST be recorded in both directions: every superseded_by or partially_superseded_by entry on an ADR MUST be matched by a supersedes or partially_supersedes entry, respectively, on the ADR it names, and every successor-side entry MUST be matched by the reverse pointer on the ADR it names. Each of the four fields MUST accept a list of ADRs as well as a single one, and the loader MUST fail when any pair is one-sided, comparing entries by ADR number whatever form the pointer is written in.A successor can retire several ADRs (ADR-0099 retired two), which a single-valued field could not record, and with only the forward pointer checked the successor side drifted out of date. An agent reading the successor needs to see what it replaced, and a loader check fails a one-sided link in CI. Planned in #3848 and built in #4351. |
Depends on: MS-14-005 |
Metadata Loader Failure Attribution
Rules for how the file-walking loaders under vultron/metadata/ report a file they cannot read. These loaders back the pre-commit hooks and the spec-dump / spec-lint / append-history / adr-index entry points, so their error output is the first thing an author sees after a bad edit.
| ID | Priority | Requirement | Related |
|---|---|---|---|
MS-17-001 |
MUST | A loader under vultron/metadata/ that walks a set of files and validates each one MUST attribute every failure to the file that caused it, naming the repository-relative path and — when the underlying parser supplies a position — the line and column, in the form path:line:col. A message that reports only a position, or only a positional index into the parsed structure, does not satisfy this requirement.Each of these loaders reads a whole directory, so a position without a filename identifies nothing: the corpus under specs/ alone spans dozens of files in which "line 404" is ambiguous. PyYAML compounds this, because a loader that hands it text rather than a named stream gets a mark reading <unicode string> — a position attributed to a source that does not exist on disk. The file name is knowledge only the loader holds, so only the loader can supply it. |
|
MS-17-002 |
MUST NOT | A YAML parse error or a schema-validation error MUST NOT escape such a loader as an unhandled traceback. Both the parse and the model validation of each file MUST be reported as a ValueError, the type these loaders already document raising.A YAML scanner error is not a ValueError, so a caller guarding except (ValidationError, ValueError) never sees it and the tool exits through a traceback instead of its own error format, in which no frame names the offending file. Which step raised also decides the output's shape: a parse fault escapes as a traceback and suppresses the loader's own reporting, while a validation fault is caught and rendered as the tool's error line. |
|
MS-17-003 |
MUST | The attribution required by MS-17-001 and MS-17-002 MUST be obtained from a single shared helper in vultron/metadata/file_loading.py, not reimplemented per loader. A loader that walks files MUST route both its parse step and its validation step through that helper.The same wrapper was written independently in the ADR loader and in the history entry loaders, in as many message shapes as there were copies, while the notes and spec loaders were left without one — so what an author sees depends on which directory they edited rather than on what they got wrong. A shared helper is also the only way an improvement reaches all of them: no hand-written copy read the parser's position mark, and the history CLI's copy reported no path at all. |
|
MS-17-004 |
MUST | An exception class raised by the shared helper to report loader failures MUST be a ValueError subclass defined within the tooling layer. The VultronError hierarchy required by EH-01-002 governs protocol code and MUST NOT be extended into vultron/metadata/.SR-03-009 makes this the tooling layer's first custom exception class, which is what brings EH-01-002 into contact with it — today's loaders raise the built-in ValueError, so that rule has never applied here. Obeying it now would invert the contract: VultronError derives from Exception, not ValueError, so a caller guarding except (ValidationError, ValueError) would miss the new error as it misses today's scanner error. vultron/errors.py is the protocol domain's; this is tooling. |
Refines: MS-17-002 — fixes the concrete exception type that satisfies the ValueError requirement |
Multi-Actor Demo
Requirements for multi-actor demo scenarios, Docker Compose orchestration, actor isolation, acceptance testing, and reproducibility. Includes per-scenario required semantic-type lists for the case-ledger invariant harness.
File ID: DEMOMA
Cross-Actor Communication
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-00-001 |
MUST | Cross-actor communication MUST occur exclusively via HTTP using the ActivityStreams inbox endpoint |
Actor Isolation
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-01-001 |
MUST | Each actor in a multi-actor demo MUST run in a separate container with its own DataLayer instance | |
DEMOMA-01-002 |
MUST | Each actor container MUST expose the actor's identity via its /actors/{actor_id} profile endpoint so that other containers can discover it at startup |
|
DEMOMA-01-003 |
MUST | Demo startup MUST automatically reset all actor state to a known clean baseline before the scenario executes, without requiring manual user intervention | |
DEMOMA-01-004 |
MUST | Each call to create_app() MUST create fresh per-app instances of the dispatcher, emitter, and DataLayer stored on app.state; create_app() MUST NOT mutate module-level singleton state such as _DEFAULT_EMITTER_SLOT, _DISPATCHER_SLOT, or _shared_instanceWhen multiple Vultron actors are co-located in the same Python process (e.g., a report-receiver actor and its spawned Case Actor), each call to create_app() must produce a fully isolated application. If create_app() mutates module-level globals, the last lifespan to call configure_default_emitter() or init_dispatcher() silently overwrites all prior actors' state, causing cross-actor data leakage. |
Refines: DEMOMA-01-001 |
DEMOMA-01-005 |
MUST | Co-located actors (multiple FastAPI app instances in the same process) MUST each have a dedicated DataLayer instance registered in their app.dependency_overrides; no two co-located apps in the same process MAY share a DataLayer instanceSharing a DataLayer instance across co-located actors bypasses the outbox → inbox delivery path: objects created by Actor A become immediately visible to Actor B without any delivery occurring. This makes integration tests unreliable and hides real cross-actor delivery bugs. |
Refines: DEMOMA-01-001 |
Container Orchestration
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-02-001 |
MUST | Docker Compose configurations for multi-actor demos MUST include a healthcheck for each actor service that probes the /health/ready endpoint |
Refines: IMPLTS-05-002 — tech-stack.md Depends on: OB-05-002 — observability.md |
DEMOMA-02-002 |
MUST | Dependent services (e.g., a demo orchestration container) MUST use condition: service_healthy for all actor service dependencies |
|
DEMOMA-02-003 |
MUST | Port mappings, network names, and required environment variables MUST be documented in the docker-compose.yml file or an accompanying README.md |
Acceptance Tests
Scenario Coverage
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-04-002 |
SHOULD | Each scenario SHOULD include a summary of the expected CVD workflow steps and the expected final RM/EM/CS state for each actor |
Demo Trigger Protocol
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-05-001 |
MUST | All actor-initiated actions in scenario demos MUST be driven through the trigger API (POST /actors/{actor_id}/trigger/{behavior-name}), not by direct inbox injection or other mechanisms that bypass the actor's own protocol logicUsing trigger endpoints ensures that the demo exercises the same code paths that a real actor would use, including BT execution, outbox queuing, and downstream cascading behaviors. Direct inbox injection spoofs protocol messages and does not validate that the initiating actor's behavior is correctly implemented. |
Depends on: TRIG-01-001 — trigger endpoint format |
DEMOMA-05-002 |
MUST NOT | Scenario demo scripts MUST NOT construct and POST raw ActivityStreams JSON directly to an actor's inbox as a substitute for calling that actor's trigger endpoint | Refines: DEMOMA-00-001 |
DEMOMA-05-003 |
MAY | Exchange demos located under vultron/demo/exchange/ MAY use direct inbox injection (constructing and posting raw ActivityStreams JSON) to simulate the receipt of a specific wire message. Scenario demos under vultron/demo/scenario/ MUST NOT use direct inbox injection (DEMOMA-05-001 and DEMOMA-05-002 apply without exception to scenario demos).Exchange demos are low-level wire-format verification scripts that intentionally exercise the inbox parsing path directly. Scenario demos must exercise the full actor behavior including trigger dispatch; direct inbox injection in a scenario demo bypasses the triggering actor's BT execution and produces a scenario that does not test the protocol from end to end. This carve-out resolves the apparent contradiction between DEMOMA-05-001 (which would prohibit exchange demos) and the existing vultron/demo/exchange/ code. |
Refines: DEMOMA-05-001 |
DEMOMA-05-004 |
MUST NOT | Demo trigger endpoints and demo helper functions MUST NOT manipulate an actor's DataLayer directly (e.g., calling add_participant_status or equivalent) or write directly to an actor's outbox as a substitute for invoking a triggerable use case. Puppeteering an actor means calling a trigger endpoint that invokes the actor's own use case, which in turn emits the correct protocol message and awaits any required round-trip confirmation (e.g., a CaseActor ledger entry).Direct DataLayer writes bypass the protocol entirely: no ActivityStreams message is emitted, no CaseActor ledger round-trip occurs, and the actor's local state is never established through the protocol. This produces demos that appear to work but do not exercise the real protocol path. For example, a demo that stamps RM.CLOSED directly via add_participant_status gives the appearance that the participant left the case, but no Leave(VulnerabilityCase) message was ever sent or confirmed by the CaseActor. |
Refines: DEMOMA-05-001 |
rv Scenario Workflow
Trigger: Scenario Start — rv
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-06-001 |
MUST | The rv (Reporter + Vendor) demo scenario MUST execute a complete CVD lifecycle ending in final case state VFDPxa (Vendor aware, Fix ready, Fix Deployed, Public aware, no exploit public, no active attacks) | |
DEMOMA-06-002 |
MUST | The rv scenario script MUST verify the following protocol checkpoints in order, checking both the Vendor and Reporter DataLayers at each checkpoint: (1) report received (RM.RECEIVED), (2) report validated (RM.VALID), (3) case engaged (RM.ACCEPTED), (4) embargo active (EM.ACTIVE), (5) fix ready (CS.VFd), (6) fix deployed (CS.VFD), (7) publicly disclosed (CS.VFDPxa).ECA DetailsPreconditions - Two-actor scenario executing; Vendor, Reporter, and CaseActor containers running and reachable; report submitted by Reporter to Vendor Steps 1. [demo-script] Verify case creation: 3 participants (Vendor, Reporter, CaseActor) and EM.ACTIVE; check Vendor DataLayer → Vendor DataLayer holds VulnerabilityCase with 3 participants and EM state = ACTIVE 2. [demo-script] Verify case replica sync: Reporter has case replica with matching participant index and active embargo; check Reporter DataLayer → Reporter DataLayer holds case replica; participant index matches Vendor's; embargo state = ACTIVE 3. [demo-script] Verify notes exchange: check both DataLayers for question note and reply note → Question note and reply note both present in canonical case state on Vendor and Reporter replicas 4. [demo-script] Verify fix-ready state: CS.VF on all replicas → Vendor and Reporter replicas both show CS = VF (vendor aware, fix ready) 5. [demo-script] Verify fix-deployed state: CS.VFD on all replicas → Vendor and Reporter replicas both show CS = VFD (fix deployed) 6. [demo-script] Verify public-disclosure state: CS.VFDPxa and EM terminated on all replicas → All replicas show CS = VFDPxa; EM state = EXITED 7. [demo-script] Verify case close: all participants RM.CLOSED and case closed → All participants RM state = CLOSED in the CASE_MANAGER's store; case record marked closed; each participant's replica ends at the entries recording its own closure (CM-23-004) Postconditions - All checkpoints verified; rv scenario complete in final state VFDPxa with EM.EXITED and all participants RM.CLOSED |
|
DEMOMA-06-003 |
MUST | Milestone verification MUST read both the Vendor DataLayer and the Reporter DataLayer directly; direct DataLayer reads are acceptable in demo scripts for verification (read-only assertions), but MUST NOT be used to write or manipulate actor state | |
DEMOMA-06-004 |
MUST | The rv demo Docker topology MUST use a dedicated case-actor container with its own isolated DataLayer; the Vendor MUST route case-actor creation to that container using the pre-configured case_actor_service_url from ActorConfig, not derive the case-actor URL from server_base_url (per CP-08-001 through CP-08-003).Routing case-actor creation to its own container satisfies DEMOMA-01-001 (each actor in its own container) and exercises the CaseProposal protocol (ADR-0023) in the rv scenario. ResolveCaseActorUrlsNode reads case_actor_service_url from ActorConfig; the Vendor container MUST supply VULTRON_ACTOR__CASE_ACTOR_SERVICE_URL pointing to the dedicated CaseActor container (CP-08-003). |
Refines: DEMOMA-01-001 Refines: CP-08-001 |
DEMOMA-06-005 |
MUST | The rv demo MUST verify that the case-actor container's DataLayer holds the canonical case ledger after the demo completes; the demo script MUST dump the case-actor's ledger from the dedicated case-actor container, not from the vendor container's co-located sub-actor | Refines: DEMOMA-06-004 |
DEMOMA-06-006 |
MUST | Before calling verify_fix_ready, verify_fix_deployed, or verify_publicly_disclosed, demo scripts MUST first call wait_for_participant_vfd_state on every container that the helper will assert against — including the receiver container. Using a polling wait only on the reporter container and then immediately asserting on the receiver container with a single-shot check is not permitted.Each of these helpers calls _check_participant_vfd_state_in or _assert_participant_vfd_pxa as a single-shot, non-retrying assertion. VFD state updates are broadcast asynchronously via Announce(CaseLedgerEntry) fan-out; if the broadcast has not yet reached the receiver container when the assertion fires, the check fails with a spurious "vfd_state is not VFD" error. Adding a wait_for_participant_vfd_state poll on every target container before the single-shot assertion eliminates this race. Additionally, the receiver_actor_id argument MUST be the full URI of an actor that holds CVDRole.VENDOR in the case (see DEMOMA-15-001); passing a Coordinator or other non-Vendor actor whose VFD path never advances will always fail the VFD check. |
Refines: DEMOMA-06-002 Refines: DEMOMA-15-001 |
ParticipantStatus Trigger Pattern
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-07-001 |
MUST | CS state transitions (fix-ready, fix-deployed, published) MUST be driven through participant status updates via Add(ParticipantStatus, target=Case) sent to the Case Actor inbox, not through dedicated low-level state-machine endpoints. RM case closure is explicitly excluded from this requirement: RM.CLOSED MUST be reached via the Leave(VulnerabilityCase) round-trip (CM-23-001), not via Add(ParticipantStatus, rmState=CLOSED). PR #1909 (issue #1858) changed RM case closure to flow through Leave(VulnerabilityCase) → Case Actor ledger commit → broadcast, rather than Add(ParticipantStatus, rmState=CLOSED). This makes the Case Actor the authority for closure and prevents ghost-departures from lost messages. See ADR-0050. |
Implements: CM-23-001 |
DEMOMA-07-002 |
MUST | Demo trigger endpoints for participant status transitions MUST use the "notify" naming convention (e.g., notify-fix-ready, notify-fix-deployed, notify-published) to avoid semantic collision with the ActivityStreams Announce verb | |
DEMOMA-07-003 |
MUST | The Case Actor received handler for ADD_PARTICIPANT_STATUS_TO_PARTICIPANT MUST implement the following ordered steps as a BT Sequence: (1) verify the activity actor is a known case participant (precondition guard), (2) update the participant status record, (3) propagate the update to the other case participants that receive case content (CM-10-004, CM-23-004) via Announce(CaseLedgerEntry) fan-out (per ADR-0019 and SYNC-02-002) — NOT via a direct raw Add(ParticipantStatus) re-broadcast, (4) if CS.P is newly set by the Case Owner, trigger embargo teardown automatically, (5) if all participants are RM.CLOSED, close the case automatically. Each step MUST be implemented as a distinct BT node or subtree so that the protocol logic is visible in the tree structure and not hidden inside node update() methods.ECA DetailsPreconditions - Receiving actor is CASE_MANAGER (Case Actor) - Inbound activity actor is a known case participant Steps 1. [case_actor] Verify inbound activity actor is a known case participant → Actor found in case.actor_participant_index; FAILURE if unknown sender 2. [case_actor] Append ParticipantStatus to the participant record → Participant's latest status reflects the newly received state 3. [case_actor] Commit a canonical CaseLedgerEntry and fan-out Announce(CaseLedgerEntry) to the case's content recipients (CM-10-004, CM-23-004) → Every active peer not at RM.CLOSED receives the status update via the canonical log path (ADR-0019, SYNC-02-002), and so does a closed participant the entry records the closure of; no other closed participant receives it, and no raw Add(ParticipantStatus) re-broadcast is emitted 4. [case_actor] If the new CaseStatus (derived from the participant status update) indicates at least one of CS.P, CS.X, or CS.A and an active embargo exists, trigger embargo teardown automatically via ThreatTerminationBranchNode (RSH-03-001). An EmbargoTeardownAuthorizationGate call-out (RSH-02-001) precedes the teardown node; it defaults to AlwaysSucceed (auto-approve) but is designed to accommodate a future "ask CASE_OWNER to confirm" policy. The CASE_OWNER gate previously stated here is incorrect — the code fires teardown for any participant's CS.P/X/A, not only the Case Owner's. See ThreatTerminationBranchNode (RSH-03-002: no sender-role gate) and ADR-0046. → Embargo teardown triggered (EM.EXITED) when any participant's CaseStatus carries CS.P, CS.X, or CS.A and an active embargo is present 5. [case_actor] SUPERSEDED This step previously described an all-participants-RM.CLOSED check that emitted Leave(VulnerabilityCase) from the Case Actor to trigger case closure. This step is retired: RM case closure now flows through each participant's own Leave(VulnerabilityCase) round-trip (CM-23-001). The owner-Leave path (CM-23-002) replaces this step; when the Case Owner sends Leave(VulnerabilityCase), the Case Actor closes the case and advances the CaseActor participant to RM.CLOSED (CM-23-004, ADR-0051). Implementation: see #1901. → SUPERSEDED No AutoCloseSequence fires in add_participant_status_tree. Case closure is triggered by Leave(VulnerabilityCase) from the Case Owner. Postconditions - Participant record updated; CaseLedgerEntry committed and broadcast; embargo teardown triggered when any participant's CaseStatus carries CS.P, CS.X, or CS.A and an active embargo exists. |
Refines: SYNC-02-002 — step 3 propagation must use canonical Announce(CaseLedgerEntry) fan-out |
DEMOMA-07-006 |
MUST | SUPERSEDED by CM-23-001 and ADR-0050 The BT node decomposition requirements for DEMOMA-07-003 step 5 (AllParticipantsRMClosedConditionNode, CloseNotYetEmittedConditionNode, EmitCloseCaseNode) are retired from the add_participant_status_tree path. Those nodes MUST NOT be wired into add_participant_status_tree; they were live dead code after PR #1909 because the AutoCloseSequence evaluated AllParticipantsRMClosedConditionNode only when Add(ParticipantStatus) arrived, which always precedes any Leave and therefore could never observe all participants at RM.CLOSED. The equivalent closure logic belongs in the Leave(VulnerabilityCase) receive path, implemented by #1901 (CM-23-002, CM-23-003, ADR-0051). | Refines: DEMOMA-07-003 — Step 5 is superseded; AutoCloseSequence removed from add_participant_status_tree |
DEMOMA-07-004 |
SHOULD | The participant status trigger implementation SHOULD be layered: SvcAddObjectToCaseUseCase (generic Add(object, target=Case)) as the base, SvcAddParticipantStatusUseCase (new core use case) above it, and demo-specific endpoints in demo_triggers.py at the top | |
DEMOMA-07-005 |
MUST NOT | The Case Actor received handler for ADD_PARTICIPANT_STATUS_TO_PARTICIPANT MUST NOT send a raw Add(ParticipantStatus) re-broadcast directly to peer participants after updating the local participant record. Announce(CaseLedgerEntry) fan-out is the sole canonical propagation path for status updates to peers (DEMOMA-07-003 step 3); a parallel raw re-broadcast causes every peer to receive and process the same status update twice — once as a raw Add(ParticipantStatus) and once wrapped in Announce(CaseLedgerEntry) — producing duplicate state transitions and non-canonical messages in peer inboxes. This artefact is superseded by ADR-0019. | Refines: DEMOMA-07-003 — prohibits the pre-SYNC raw re-broadcast artefact superseded by ADR-0019 Refines: SYNC-02-002 — Announce(CaseLedgerEntry) is the sole canonical propagation path for status updates |
Case Actor Bootstrap and CASE_MANAGER Role
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-08-001 |
MUST | The vendor MUST send Create(VulnerabilityCase) (not Announce) to the Reporter as the trust bootstrap after case initialization; only the Case Actor uses Announce for subsequent case updates. | Refines: CBT-01-001 — original report submission path bootstrap |
DEMOMA-08-003 |
MUST NOT | CASE_MANAGER role delegation MUST NOT reuse or adapt the existing OFFER_CASE_OWNERSHIP_TRANSFER, ACCEPT_CASE_OWNERSHIP_TRANSFER, or REJECT_CASE_OWNERSHIP_TRANSFER message semantics; these are distinct protocol operations with different semantics and different effects on case ownership | |
DEMOMA-08-004 |
MUST | When the Case Actor accepts the CASE_MANAGER delegation, it MUST run the case initialization BT: establish the default embargo with both Vendor and Reporter immediately as signatories (no invite/accept round-trip), and set EM state to ACTIVE before the trust bootstrap is sent to the Reporter | |
DEMOMA-08-005 |
MUST | All nested domain objects in the bootstrap Create(VulnerabilityCase) activity (e.g., CaseParticipant objects in case_participants) MUST be embedded as full inline objects, not as bare URI string referencesReceiving handlers store nested objects by iterating the embedded collection and persisting each object individually. Bare URI string references cannot be deserialized to typed domain objects; the referenced objects are never written to the receiver's DataLayer. Downstream BT nodes (e.g., SenderIsParticipantNode) then fail because participant records are absent. |
Refines: CBT-01-007 |
DEMOMA-08-010 |
MUST | The trust bootstrap payload MUST include all three participants (Vendor, Reporter, Case Actor) so the Reporter can bind the Case Actor identity to this case. Without all three participants embedded in the bootstrap payload, the Reporter cannot establish trust for the Case Actor and cannot route subsequent protocol messages correctly. |
RVV Scenario (Reporter → Vendor1 → Vendor2, no Coordinator)
Trigger: Scenario Start — rvv
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-09-001 |
MUST | The RVV scenario MUST seed three independent actor containers — Reporter, Vendor1, and Vendor2 — with no Coordinator; each container MUST have its own isolated DataLayer. | |
DEMOMA-09-002 |
MUST | Vendor1 MUST invite Vendor2 to the case after accepting the report (RM→ACCEPTED), and Vendor2 MUST accept and become a full signatory participant before the scenario advances to fix tracking.ECA DetailsPreconditions - Vendor1 RM state = ACCEPTED; VulnerabilityCase created by the CaseActor on the dedicated case-actor container; Vendor2 container running Steps 1. [vendor1] POST invite-actor-to-case trigger for Vendor2 → CaseActor receives and queues Invite(Actor, VulnerabilityCaseStub) for Vendor2 2. [demo-script] Poll Vendor2 DataLayer until Invite(Actor, VulnerabilityCaseStub) is visible → Invite activity present in Vendor2 DataLayer 3. [vendor2] POST accept-case-invite trigger → Vendor2 joins the case and becomes a signatory (active); CaseActor seeds Vendor2 case replica Postconditions - Vendor2 is a signatory participant with EM consent; Vendor2 holds a seeded case replica; scenario may advance to fix tracking |
|
DEMOMA-09-003 |
MUST | The RVV scenario MUST reach a final state of VFDPxa CS on all three actor replicas (Reporter, Vendor1, Vendor2), with EM.EXITED and RM.CLOSED for all participants; both Vendor1 and Vendor2 MUST each have their own Vendor Fix Path tracked independently | |
DEMOMA-09-004 |
MUST | The RVV scenario MUST run deterministically end-to-end via a single docker compose command using the existing multi-actor Compose stack; the scenario script MUST verify LedgerFanout replica convergence (both Reporter and Vendor2 replicas match the authoritative Vendor1 state) after each major protocol event |
|
DEMOMA-09-005 |
MUST | The PR that introduces the RVV scenario script MUST also register it with the scenario registry (DEMOCI-11-001), so that its generated .github/demo-scenarios.json entry (DEMOCI-11-004) runs it as its own CI matrix job; that job MUST run concurrently with (not after) the rv job so that total CI wall-clock time does not grow linearly with scenario count |
Refines: DEMOCI-03-004 |
DEMOMA-09-006 |
MUST | The Case Actor MUST run on the dedicated case-actor container in the RVV scenario, as in every multi-container scenario; Vendor1 proposes the case to it. RVV has no Coordinator, but the Case Actor is not a Coordinator: it is the CASE_MANAGER participant, and every actor container names the dedicated case-actor container as its case_actor_service_url (CP-08-003, CP-08-008, DEMOMA-06-004). The scenarios share one Compose stack, so RVV cannot keep the Case Actor in Vendor1's container without every other scenario doing the same. Running it there was the co-located stepping stone #1876 retired. |
Refines: CP-08-008 |
RVCV-Extension Scenario (Vendor1 keeps ownership; Coordinator suggests Vendor2)
Trigger: Scenario Start — rvcv-extension
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-10-001 |
MUST | The RVCV-extension scenario MUST involve four actor identities — Reporter, Vendor1, Coordinator, and Vendor2 — each with its own isolated DataLayer; the CaseActor MUST run as a separate actor identity with its own container and DataLayer, not co-located in the Vendor1 container. | Refines: DEMOMA-01-001 |
DEMOMA-10-002 |
MUST | Vendor1 MUST create the case (RM→ACCEPTED) and retain CVDRole.CASE_OWNER throughout the entire scenario. Ownership MUST NOT transfer to the Coordinator.This scenario demonstrates the "extension" variant: a vendor seeks facilitation help without surrendering case control, in contrast to the RVCV-handoff variant where ownership transfers. |
Refines: CM-02-001 |
DEMOMA-10-003 |
MUST | Vendor1 MUST invite the Coordinator to the case and the Coordinator MUST accept and become a participant holding CVDRole.COORDINATOR and NOT CVDRole.CASE_MANAGER.ECA DetailsPreconditions - Vendor1 RM state = ACCEPTED; VulnerabilityCase created with CaseActor; Coordinator container running Steps 1. [vendor1] POST invite-actor-to-case trigger targeting Coordinator → CaseActor queues Invite(Actor, VulnerabilityCaseStub) for Coordinator inbox 2. [demo-script] Poll Coordinator DataLayer until Invite(Actor, VulnerabilityCaseStub) is visible → Invite activity present in Coordinator DataLayer 3. [coordinator] POST accept-case-invite trigger → Coordinator joins the case (active) with CVDRole.COORDINATOR (not CASE_MANAGER); CaseActor seeds Coordinator case replica Postconditions - Coordinator is a participant with CVDRole.COORDINATOR and NOT CVDRole.CASE_MANAGER; Coordinator holds a seeded case replica |
Refines: CM-20-001 |
DEMOMA-10-004 |
MUST | The Coordinator MUST suggest Vendor2 to the case and the CaseActor MUST transform the offer and forward it to Vendor1 for approval.ECA DetailsPreconditions - Coordinator is an accepted participant (DEMOMA-10-003 complete); Vendor2 container running; no existing suggestion for Vendor2 in the case Steps 1. [coordinator] POST suggest-actor-to-case trigger for Vendor2 → Coordinator sends Offer(Actor, Case) to CaseActor inbox 2. [case-actor] Transform Offer(Actor, Case) to Offer(CaseParticipant{Vendor2, roles=[vendor]}, Case) → Transformed offer forwarded to Vendor1 (Case Owner) inbox 3. [demo-script] Poll Vendor1 DataLayer until transformed Offer(CaseParticipant) is visible → Transformed offer present in Vendor1 DataLayer Postconditions - Vendor1 holds a pending Offer(CaseParticipant) for Vendor2 awaiting approval; no invitation has been sent to Vendor2 yet |
Refines: CM-16-001 Refines: CM-20-003 |
DEMOMA-10-005 |
MUST | Vendor1 MUST approve the Coordinator's suggestion and the CaseActor MUST then send Invite(CaseStub+embargo) to Vendor2 on Vendor1's behalf.ECA DetailsPreconditions - Vendor1 DataLayer holds pending Offer(CaseParticipant) for Vendor2 (DEMOMA-10-004 complete); Vendor2 container running Steps 1. [vendor1] POST accept-actor-recommendation trigger for Vendor2 → Vendor1 accepts Offer(CaseParticipant); CaseActor sends Invite(CaseStub+embargo) to Vendor2 2. [demo-script] Poll Vendor2 DataLayer until Invite(CaseStub+embargo) is visible → Invite activity present in Vendor2 DataLayer Postconditions - Vendor2 holds an invitation to the case; scenario may advance to Vendor2 acceptance |
Refines: CM-17-001 Refines: CM-20-002 |
DEMOMA-10-006 |
MUST | The RVCV-extension scenario MUST reach a final state with four participants — Reporter, Vendor1, Coordinator, and Vendor2 (plus the CaseActor) — and MUST advance to VFDPxa CS with EM.EXITED and RM.CLOSED for all participants. | Refines: DEMOMA-03-002 |
DEMOMA-10-007 |
MUST | The RVCV-extension scenario MUST run deterministically end-to-end; the scenario script MUST verify LedgerFanout replica convergence for Reporter, Coordinator, and Vendor2 replicas against the authoritative Vendor1 state after each major protocol event. | Refines: DEMOMA-09-004 |
DEMOMA-10-008 |
MUST | The implementation of the RVCV-extension scenario MUST add a dedicated parallel CI job that runs the scenario concurrently with existing demo CI jobs; the scenario MUST be runnable end-to-end via a single orchestration command (e.g., docker compose). |
Refines: DEMOMA-09-005 |
DEMOMA-10-009 |
MUST | The Vendor1 actor container MUST supply VULTRON_ACTOR__CASE_ACTOR_SERVICE_URL in its Docker Compose environment pointing to the dedicated CaseActor container base URL (e.g., http://case-actor:7999/api/v2). ResolveCaseActorUrlsNode MUST derive case_actor_id from this configured URL, not from server_base_url (per CP-08-001 through CP-08-003). |
Refines: CP-08-003 Refines: DEMOMA-10-001 |
RVCV-Handoff Scenario (Vendor1 hands ownership to Coordinator; it invites Vendor2)
Trigger: Scenario Start — rvcv-handoff
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-11-001 |
MUST | The RVCV-handoff scenario MUST involve four actor identities with isolated DataLayers and a separate CaseActor container, as required by DEMOMA-10-001. | Refines: DEMOMA-10-001 |
DEMOMA-11-002 |
MUST | Vendor1 MUST transfer case ownership to the Coordinator via the offer-case-ownership-transfer / accept-case-ownership-transfer handshake. This is the defining contrast with RVCV-extension (DEMOMA-10-002): ownership transfers mid-scenario rather than staying with Vendor1 throughout. The Coordinator must already hold a case replica before accepting ownership, because AcceptCaseOwnershipTransferNode reads the case from the DataLayer and returns FAILURE if the case is not found. ECA DetailsPreconditions - Vendor1 RM state = ACCEPTED; Coordinator is an existing signatory participant with a seeded case replica in their DataLayer; no pending ownership-transfer offer exists Steps 1. [vendor1] POST offer-case-ownership-transfer trigger targeting Coordinator → Vendor1's proposal queued for the CASE_MANAGER, which queues its Offer(VulnerabilityCase) for the Coordinator inbox 2. [demo-script] Poll Coordinator DataLayer until Offer(VulnerabilityCase) is visible → Offer activity present in Coordinator DataLayer 3. [coordinator] POST accept-case-ownership-transfer trigger → Coordinator CVDRole updated to CASE_OWNER; Vendor1 role updated to plain participant; VulnerabilityCase attributed_to updated to Coordinator actor ID Postconditions - Coordinator holds CVDRole.CASE_OWNER; Vendor1 is a plain participant; VulnerabilityCase.attributed_to = Coordinator actor ID; all participants' case replicas reflect the updated ownership |
Refines: TRIG-11-001 Refines: TRIG-11-002 |
DEMOMA-11-003 |
MUST | After becoming CASE_OWNER, the Coordinator MUST invite Vendor2 directly using the invite-actor-to-case trigger, and Vendor2 MUST accept the invitation before the scenario advances. In RVCV-extension, Vendor1 approves a Coordinator suggestion. In RVCV-handoff, the Coordinator is the owner and invites directly — no approval step is needed. The Vendor2 accept-invite step is mandatory: per MV-10-004 an Invite alone does not seed the replica; the CaseActor's Announce fires only after the stub Invite is accepted (CM-11-008). ECA DetailsPreconditions - Coordinator holds CVDRole.CASE_OWNER (DEMOMA-11-002 complete); Vendor2 container running; no existing Invite for Vendor2 in the case Steps 1. [coordinator] POST invite-actor-to-case trigger for Vendor2 → CaseActor queues Invite(Actor, VulnerabilityCaseStub) for Vendor2 2. [demo-script] Poll Vendor2 DataLayer until Invite(Actor, VulnerabilityCaseStub) is visible → Invite activity present in Vendor2 DataLayer 3. [vendor2] POST accept-case-invite trigger directed to CaseActor → Vendor2 joins the case and becomes a signatory (active); CaseActor emits Announce(VulnerabilityCase) to seed Vendor2 case replica 4. [demo-script] Poll Vendor2 DataLayer until case replica is seeded → VulnerabilityCase record present in Vendor2 DataLayer Postconditions - Vendor2 is a signatory participant with a seeded case replica; scenario may advance to fix tracking and final state |
Refines: DEMOMA-10-003 |
DEMOMA-11-004 |
MUST | The VulnerabilityCase.attributed_to field MUST be updated to the Coordinator's actor ID after the ownership transfer completes. All participants MUST observe the updated attributed_to in their case replicas. |
Refines: DEMOMA-10-002 |
DEMOMA-11-005 |
MUST | An active embargo initiated before an ownership transfer MUST remain ACTIVE through and after the transfer; the EM state machine MUST transition correctly through the ownership change. | Refines: DEMOMA-10-006 |
DEMOMA-11-006 |
MUST | The RVCV-handoff scenario MUST reach a final state with four participants — Reporter, Vendor1, Coordinator, and Vendor2 (plus CaseActor) — and MUST advance to VFDPxa CS with EM.EXITED and RM.CLOSED for all participants, as required by DEMOMA-10-006. | Refines: DEMOMA-10-006 |
DEMOMA-11-007 |
MUST | The RVCV-handoff scenario MUST run deterministically and MUST verify LedgerFanout replica convergence for all non-owning participants after each major protocol event, as required by DEMOMA-10-007. | Refines: DEMOMA-10-007 |
DEMOMA-11-008 |
MUST | The implementation of the RVCV-handoff scenario MUST add a dedicated parallel CI job and a CLI entry point, as required by DEMOMA-10-008. | Refines: DEMOMA-10-008 |
DEMOMA-11-009 |
MUST | The RVCV-handoff scenario script MUST include polling wait steps at each asynchronous protocol boundary before driving the next trigger. Each of these polling steps was required in RVCV-extension (see ISSUE-1535 post-review fixes). Omitting any wait step causes the scenario to proceed on state that has not yet been committed to the target actor's DataLayer, producing consistent CI failures rather than intermittent flakes. This requirement is the RVCV-handoff instance of the general rule DEMOMA-22-001 (see relationships). ECA DetailsPreconditions - RVCV-handoff scenario executing; Vendor1 has just emitted the ownership-transfer offer and the script is driving subsequent async steps Steps 1. [demo-script] After emitting the ownership-transfer offer: poll Coordinator's DataLayer until Offer(VulnerabilityCase) is visible → Offer activity present in Coordinator DataLayer 2. [demo-script] After Coordinator accepts the offer: poll Vendor1's case replica until attributed_to reflects the Coordinator's actor ID → Vendor1 case replica shows VulnerabilityCase.attributed_to = Coordinator actor ID 3. [demo-script] After Coordinator invites Vendor2: poll Vendor2's DataLayer until Invite(Actor, VulnerabilityCaseStub) is visible → Invite activity present in Vendor2 DataLayer 4. [demo-script] After Vendor2 accepts the invitation: poll Vendor2's DataLayer until the case replica seeded by CaseActor's Announce(VulnerabilityCase) is present → VulnerabilityCase record present in Vendor2 DataLayer Postconditions - All four async boundaries confirmed; each actor's DataLayer reflects the expected state before the next trigger is driven; scenario proceeds deterministically |
Refines: DEMOMA-10-007 Refines: DEMOMA-22-001 — This requirement is the RVCV-handoff instance of the general rule DEMOMA-22-001, which applies the same constraint to every scenario and additionally constrains what the wait may observe (EDF-06-001 through EDF-06-003). "A polling wait exists here" is necessary but not sufficient. This requirement remains active; it is not superseded. |
DEMOMA-11-010 |
MUST | The Vendor1 actor container MUST supply VULTRON_ACTOR__CASE_ACTOR_SERVICE_URL in its Docker Compose environment pointing to the dedicated CaseActor container base URL (e.g., http://case-actor:7999/api/v2). ResolveCaseActorUrlsNode MUST derive case_actor_id from this configured URL, not from server_base_url (per CP-08-001 through CP-08-003). |
Refines: CP-08-003 Refines: DEMOMA-10-009 |
DEMOMA-11-011 |
MUST | After the RVCV-handoff ownership transfer, the Coordinator MUST hold CVDRole.CASE_OWNER. The ownership transfer postcondition for the Coordinator: it becomes the case owner. |
|
DEMOMA-11-012 |
MUST | The CaseActor MUST seed Vendor2's case replica on Vendor2's acceptance of the RVCV-handoff invitation. Replica seeding is the CaseActor's responsibility after each new participant accepts an invitation. |
|
DEMOMA-11-013 |
MUST | All participants (including Vendor2 after joining) MUST be subject to an active embargo that was established before the RVCV-handoff ownership transfer. Ownership transfer does not alter PEC consent rows; all prior signatories and any late-joining participants remain bound by the active embargo. |
|
DEMOMA-11-014 |
MUST | After the RVCV-handoff ownership transfer, Vendor1 MUST become a plain participant (no longer holding CVDRole.CASE_OWNER). The ownership transfer postcondition for Vendor1: it relinquishes the case-owner role and becomes an ordinary participant. |
RCV Scenario (Reporter reports to Coordinator; Coordinator invites Vendor)
Trigger: Scenario Start — rcv
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-12-001 |
MUST | The RCV scenario MUST involve four actor identities — Reporter, Coordinator, Vendor, and CaseActor — each with its own isolated DataLayer and separate container. The CaseActor MUST run as a separate actor identity with its own container and DataLayer, not co-located on the Coordinator container. RCV is the canonical three-party coordination baseline. Using four separate containers (Reporter, Coordinator, Vendor, CaseActor) matches the RVCV-extension topology (DEMOMA-10-001) and ensures the demo exercises true multi-container message delivery rather than in-process calls. |
Refines: DEMOMA-01-001 |
DEMOMA-12-002 |
MUST | The Coordinator MUST receive the Reporter's report (RM→RECEIVED), validate it (RM→VALID), and engage the case (RM→ACCEPTED), which triggers CaseActor creation on the Coordinator's container. RCV is the canonical Coordinator-as-owner scenario: Reporter reports to Coordinator, Coordinator creates the case. This contrasts with RVV (Vendor is owner) and RVCV-extension (Vendor retains ownership). The CaseActor service actor holds CASE_MANAGER alongside COORDINATOR role per glossary; the Coordinator human actor holds CASE_OWNER only. ECA DetailsPreconditions - All four containers running (Reporter, Coordinator, Vendor, CaseActor); no VulnerabilityCase exists for this report; Coordinator auto_create_case=True; Coordinator pre-configured with the dedicated CaseActor container URL Steps 1. [reporter] POST submit-report trigger directed at Coordinator → Report delivered to Coordinator inbox 2. [coordinator] BT processes inbound report; RM transitions RECEIVED→VALID→ACCEPTED → VulnerabilityCase created; CaseActor registered on the separate CaseActor container (not co-located on the Coordinator container, per DEMOMA-12-001); default embargo initialized to EM.ACTIVE (EP-04-001); EM.ACTIVE is established at case creation before any participant invitation 3. [coordinator] Coordinator assigned CVDRole.CASE_OWNER on the new case → Coordinator holds CASE_OWNER; CaseActor holds CASE_MANAGER 4. [coordinator] Coordinator sends trust-bootstrap Create(VulnerabilityCase) to Reporter (CBT-01-001); Reporter is the original report submitter so MUST receive this one-time bootstrap to trust the CaseActor identity → Reporter receives Create(VulnerabilityCase) carrying Coordinator as CASE_OWNER and CaseActor as COORDINATOR; Reporter trusts CaseActor for this case Postconditions - VulnerabilityCase exists; Coordinator RM=ACCEPTED, CVDRole=CASE_OWNER; CaseActor running with CASE_MANAGER on its own container; EM.ACTIVE established; trust bootstrap delivered to Reporter; no ownership transfer has occurred |
Refines: CM-02-001 Refines: CBT-01-001 Refines: EP-04-001 |
DEMOMA-12-003 |
MUST | The Coordinator MUST NOT invite the Reporter to the case. The Reporter is the reporter, so the CASE_MANAGER seats it as a signatory participant when it creates the case (CM-22-002, CM-14-005). The scenario MUST confirm that the Reporter is a participant and holds a case replica before the Vendor is invited. The reporter's consent is implicit in submitting the report (CM-14-005), so an Invite/Accept exchange for the Reporter would record an invitation that never occurred (CM-18-003). ECA DetailsPreconditions - DEMOMA-12-002 postconditions satisfied: VulnerabilityCase exists, Coordinator RM=ACCEPTED, CVDRole=CASE_OWNER, EM.ACTIVE, trust bootstrap Create(VulnerabilityCase) already delivered to Reporter (CBT-01-001) Steps 1. [demo_script] Wait until the Coordinator's case lists the Reporter and the Coordinator as participants → Reporter present as a participant seated at case creation, with no Invite/Accept exchange (the case-actor log's single invite_actor_to_case entry, the Vendor's, is checked by DEMOMA-12-009(2)) 2. [demo_script] Verify the case is active and the Reporter holds a case replica → Reporter holds a case replica with EM.ACTIVE Postconditions - Reporter is a signatory with a case replica; Vendor invitation has not yet been issued |
Refines: CM-22-002 Refines: CM-14-005 |
DEMOMA-12-004 |
MUST | The Coordinator MUST invite Vendor to the case using the invite-actor-to-case trigger directly (not via a suggestion/approval chain); Vendor MUST accept via the accept-case-invite trigger.Coordinator is CASE_OWNER, so a direct invite-actor-to-case is correct; the ADR-0026 suggest/approve chain is for participants recommending actors to the case owner, not for the owner inviting directly. Polling before accept is required for determinism (per DEMOMA-11-009 pattern).ECA DetailsPreconditions - Reporter is a signatory with a case replica (DEMOMA-12-003 postconditions satisfied); Vendor is not yet a case participant Steps 1. [coordinator] POST invite-actor-to-case trigger for Vendor → Invite(Actor, VulnerabilityCaseStub) delivered to Vendor inbox 2. [demo_script] Poll Vendor DataLayer until Invite(Actor, VulnerabilityCaseStub) is visible → Invitation record present in Vendor replica 3. [vendor] POST accept-case-invite trigger → Vendor becomes a signatory; CaseActor emits Announce(VulnerabilityCase) to seed Vendor replica with full case history Postconditions - Vendor is a signatory with a seeded case replica containing full history from genesis; all three actors (Reporter, Coordinator, Vendor) plus CaseActor are now case participants |
Refines: CM-17-001 |
DEMOMA-12-005 |
MUST | The RCV scenario MUST reach a final state with three participants — Reporter, Coordinator, and Vendor (plus the CaseActor) — advancing to VFDPxa CS with EM.EXITED and RM.CLOSED for all participants; only Vendor's fix path (VFD) is tracked. | Refines: DEMOMA-03-002 Refines: EP-04-001 |
DEMOMA-12-006 |
MUST | The RCV scenario MUST run deterministically end-to-end and MUST verify LedgerFanout replica convergence for Reporter and Vendor replicas against the authoritative Coordinator/CaseActor state after each major protocol event. | Refines: DEMOMA-09-004 |
DEMOMA-12-007 |
MUST | The implementation of the RCV scenario MUST register it with the scenario registry (DEMOCI-11-001) in the same PR, so that its generated .github/demo-scenarios.json entry (DEMOCI-11-004) runs it as its own CI matrix job and CI failures are independently reported per DEMOCI-03-003. |
Refines: DEMOCI-03-002 Refines: DEMOCI-03-003 |
DEMOMA-12-008 |
MUST | The RCV implementation MUST ship a dedicated per-scenario case-ledger invariant test file (test/ci/invariants/test_rcv_invariants.py) built exclusively on the shared invariant library introduced by the harness refactor (issue #1592); DEMOMA-12-008 is satisfied only when that shared library exists.Requiring use of the shared library enforces the DRY constraint across scenarios. A file that copies invariant logic is not compliant even if it tests the correct things. |
Refines: DEMOMA-12-006 |
DEMOMA-12-009 |
MUST | The RCV per-scenario invariant test file MUST assert the following scenario-specific checks in addition to the universal library checks: (1) validate_report event present in case-actor log, (2) invite_actor_to_case present exactly once (the Vendor invitation; the Reporter is seated, not invited, per DEMOMA-12-003), (3) close_case present, (4) CS transitions VFd and VFD observed in add_participant_status_to_participant entries for the Vendor actor and public-aware (P) transition observed in add_participant_status_to_participant entries for the Coordinator actor, and (5) Vendor replica holds the complete log from genesis (full history backfill, as Vendor is a late joiner).Scenario-specific invariants prevent the RCV scenario from accidentally omitting protocol steps not covered by universal checks. Explicitly enumerating them here means CI will catch regressions in the steps unique to RCV. |
Refines: DEMOMA-12-008 |
DEMOMA-12-010 |
MUST | The Coordinator actor container MUST supply VULTRON_ACTOR__CASE_ACTOR_SERVICE_URL in its Docker Compose environment pointing to the dedicated CaseActor container base URL (e.g., http://case-actor:7999/api/v2). ResolveCaseActorUrlsNode MUST derive case_actor_id from this configured URL, not from server_base_url (per CP-08-001 through CP-08-003). |
Refines: CP-08-003 Refines: DEMOMA-12-001 |
DEMOMA-12-011 |
MUST | The Coordinator MUST hold CVDRole.CASE_OWNER throughout the entire RCV scenario; ownership MUST NOT transfer to any other actor.RCV is the canonical Coordinator-as-owner scenario. Retaining CASE_OWNER throughout differentiates it from RVCV-extension and RVCV-handoff variants. |
|
DEMOMA-12-012 |
MUST | The CaseActor MUST emit Announce(VulnerabilityCase) to seed Vendor's case replica after Vendor accepts the invitation in the RCV scenario.Replica seeding via Announce is the CaseActor's canonical response to an accepted invitation (PCR-02-002). |
|
DEMOMA-12-013 |
MUST | The RCV demo script MUST poll Vendor's DataLayer for the arriving invitation before driving the accept step. Polling before accept is required for determinism; driving accept before the invitation is visible in Vendor's DataLayer causes a race condition. |
|
DEMOMA-12-014 |
MUST | RCV demo scripts MUST verify EM.ACTIVE at the case-creation milestone and EM.EXITED at the final milestone. The causal chain EM.ACTIVE (established at case creation per EP-04-001) to EM.EXITED (triggered by CS.P per DEMOMA-07-003(4)) must be confirmed at both endpoints. |
|
DEMOMA-12-015 |
MUST | The RCV invariant test file MUST NOT duplicate universal invariant logic; universal chain/hash/RM-closed checks MUST be imported from the shared library, not re-implemented inline. Copying invariant logic violates the DRY constraint enforced by the shared library architecture introduced by issue #1592. |
|
DEMOMA-12-016 |
MUST | The P-set CS state MUST be visible in all replicas via Announce(CaseLedgerEntry) fan-out after the Coordinator triggers CS.P. Announce(CaseLedgerEntry) is the sole canonical propagation path for status updates to peers (SYNC-02-002); this invariant confirms the fan-out fired. |
RCCV-Extension Scenario (C1 retains ownership; C2 is participant; C2 suggests Vendor)
Trigger: Scenario Start — rccv-extension
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-13-001 |
MUST | The RCCV-extension scenario MUST involve four actor identities — Reporter, Coordinator1 (C1), Coordinator2 (C2), and Vendor — each with its own isolated DataLayer; the CaseActor MUST run as a separate actor identity with its own container and DataLayer, not co-located in the C1 container. RCCV-extension demonstrates inter-coordinator collaboration: C1 is the Case Owner, C2 is a participating coordinator who can suggest new actors but cannot independently invite them. The five-container topology (Reporter, C1, C2, Vendor, CaseActor) is the same as RVCV-extension (DEMOMA-10-001) with the C1 role replacing Vendor1 as Case Owner. |
Refines: DEMOMA-01-001 Refines: DEMOMA-10-001 |
DEMOMA-13-002 |
MUST | C1 MUST receive the Reporter's report (RM→RECEIVED), validate it (RM→VALID), and engage the case (RM→ACCEPTED), retaining CVDRole.CASE_OWNER throughout the entire scenario. Ownership MUST NOT transfer to C2 or Vendor. This scenario demonstrates the "extension" variant where a Coordinator creates and owns a case. C1 acts as both the initial report recipient and the authority that approves all participant additions, in contrast to the RVCV-extension variant where a Vendor holds ownership. ECA DetailsPreconditions - All five containers running (Reporter, C1, C2, Vendor, CaseActor); no VulnerabilityCase exists for this report; C1 auto_create_case=True; C1 pre-configured with the dedicated CaseActor container URL Steps 1. [reporter] POST submit-report trigger directed at C1 → Report delivered to C1 inbox 2. [c1] BT processes inbound report; RM transitions RECEIVED→VALID→ACCEPTED → VulnerabilityCase created; CaseActor registered on the separate CaseActor container; default embargo initialized to EM.ACTIVE (EP-04-001) 3. [c1] C1 assigned CVDRole.CASE_OWNER on the new case → C1 holds CASE_OWNER; CaseActor holds CASE_MANAGER 4. [c1] C1 sends trust-bootstrap Create(VulnerabilityCase) to Reporter (CBT-01-001); Reporter is the original report submitter and MUST receive this bootstrap to trust the CaseActor identity → Reporter receives Create(VulnerabilityCase) carrying C1 as CASE_OWNER and CaseActor as COORDINATOR; Reporter trusts CaseActor for this case Postconditions - VulnerabilityCase exists; C1 RM=ACCEPTED, CVDRole=CASE_OWNER; CaseActor running with CASE_MANAGER on its own container; EM.ACTIVE established; trust bootstrap delivered to Reporter; Reporter is an initial participant via the report-submission bootstrap (not via a separate invite-actor-to-case trigger); no ownership transfer has occurred |
Refines: CM-02-001 Refines: CBT-01-001 Refines: EP-04-001 Refines: DEMOMA-12-002 |
DEMOMA-13-003 |
MUST | C1 MUST invite C2 to the case and C2 MUST accept, becoming a participant holding CVDRole.COORDINATOR and NOT CVDRole.CASE_MANAGER.ECA DetailsPreconditions - DEMOMA-13-002 postconditions satisfied: C1 RM=ACCEPTED, CVDRole=CASE_OWNER; EM.ACTIVE; trust bootstrap delivered to Reporter; Reporter is an initial participant; C2 container running Steps 1. [c1] POST invite-actor-to-case trigger targeting C2 → CaseActor queues Invite(Actor, VulnerabilityCaseStub) for C2's inbox 2. [demo-script] Poll C2 DataLayer until Invite(Actor, VulnerabilityCaseStub) is visible → Invite activity present in C2 DataLayer 3. [c2] POST accept-case-invite trigger → C2 joins the case (active) with CVDRole.COORDINATOR (not CASE_MANAGER); CaseActor seeds C2 case replica Postconditions - C2 is a participant with CVDRole.COORDINATOR and NOT CVDRole.CASE_MANAGER; C2 holds a seeded case replica; case has four participants (Reporter, C1, C2, CaseActor) |
Refines: CM-20-001 Refines: DEMOMA-10-003 |
DEMOMA-13-004 |
MUST | C2 MUST suggest Vendor to the case and the CaseActor MUST transform the offer and forward it to C1 for approval.ECA DetailsPreconditions - C2 is an accepted participant (DEMOMA-13-003 complete); Vendor container running; no existing suggestion for Vendor in the case Steps 1. [c2] POST suggest-actor-to-case trigger for Vendor with roles=["vendor"] → C2 sends Offer(Actor, Case) to CaseActor inbox 2. [case-actor] Transform Offer(Actor, Case) to Offer(CaseParticipant{Vendor, roles=[vendor]}, Case) → Transformed offer forwarded to C1 (Case Owner) inbox 3. [demo-script] Poll C1 DataLayer until transformed Offer(CaseParticipant) is visible → Transformed offer present in C1 DataLayer Postconditions - C1 holds a pending Offer(CaseParticipant) for Vendor awaiting approval; no invitation has been sent to Vendor yet |
Refines: CM-16-001 Refines: CM-20-003 Refines: DEMOMA-10-004 |
DEMOMA-13-005 |
MUST | C1 MUST approve C2's suggestion and the CaseActor MUST then send Invite(CaseStub+embargo) to Vendor on C1's behalf.ECA DetailsPreconditions - C1 DataLayer holds pending Offer(CaseParticipant) for Vendor (DEMOMA-13-004 complete); Vendor container running Steps 1. [c1] POST accept-actor-recommendation trigger for Vendor → C1 accepts Offer(CaseParticipant); CaseActor sends Invite(CaseStub+embargo) to Vendor 2. [demo-script] Poll Vendor DataLayer until Invite(CaseStub+embargo) is visible → Invite activity present in Vendor DataLayer 3. [vendor] POST accept-case-invite trigger → Vendor joins the case (active) with CVDRole.VENDOR; CaseActor seeds Vendor case replica with full history Postconditions - Vendor is a participant with CVDRole.VENDOR and a seeded case replica; all five participants present (Reporter, C1, C2, Vendor, CaseActor) |
Refines: CM-17-001 Refines: CM-20-002 Refines: DEMOMA-10-005 |
DEMOMA-13-006 |
MUST | The RCCV-extension scenario MUST reach a final state with four participants — Reporter, C1, C2, and Vendor (plus the CaseActor) — and MUST advance to VFDPxa CS with EM.EXITED and RM.CLOSED for all participants. Only Vendor tracks a vendor fix path (VFD). C1 and C2 hold CVDRole.COORDINATOR and have no VFD substate. Reporter has no VFD substate. EM.EXITED is triggered by CS.P (public disclosure). C1 (CASE_OWNER) is the actor that triggers CS.P per DEMOMA-07-003(4). |
Refines: DEMOMA-03-002 Refines: DEMOMA-10-006 |
DEMOMA-13-007 |
MUST | The RCCV-extension scenario MUST run deterministically end-to-end and MUST verify LedgerFanout replica convergence for Reporter, C2, and Vendor replicas against the authoritative C1/CaseActor state after each major protocol event. | Refines: DEMOMA-09-004 Refines: DEMOMA-10-007 |
DEMOMA-13-008 |
MUST | The implementation of the RCCV-extension scenario MUST register it with the scenario registry (DEMOCI-11-001), so that its generated .github/demo-scenarios.json entry (DEMOCI-11-004) runs it as its own CI matrix job and CI failures are independently reported per DEMOCI-03-003. |
Refines: DEMOMA-09-005 Refines: DEMOMA-10-008 |
DEMOMA-13-009 |
MUST | The RCCV-extension implementation MUST ship a dedicated per-scenario case-ledger invariant test file (test/ci/invariants/test_rccv_extension_invariants.py) built exclusively on the shared invariant library (per DEMOMA-12-008 pattern).Scenario-specific invariants prevent the RCCV-extension scenario from accidentally omitting protocol steps not covered by universal checks. The Vendor-as-late-joiner invariant is shared with RVCV-extension (DEMOMA-10) and RCCV-handoff (DEMOMA-15). |
Refines: DEMOMA-12-008 |
DEMOMA-13-010 |
MUST | The C1 actor container MUST supply VULTRON_ACTOR__CASE_ACTOR_SERVICE_URL in its Docker Compose environment pointing to the dedicated CaseActor container base URL (e.g., http://case-actor:7999/api/v2). ResolveCaseActorUrlsNode MUST derive case_actor_id from this configured URL, not from server_base_url (per CP-08-001 through CP-08-003). |
Refines: CP-08-003 Refines: DEMOMA-13-001 |
DEMOMA-13-011 |
MUST | The RCCV-extension invariant test file MUST assert the following scenario-specific checks in addition to universal library checks: (1) validate_report event present in case-actor log, (2) invite_actor_to_case present at least twice (C2 invitation + Vendor invitation), (3) close_case present, (4) CS transitions VFd and VFD observed in add_participant_status_to_participant entries for the Vendor actor and public-aware (P) transition observed in add_participant_status_to_participant entries for C1 (CASE_OWNER triggers CS.P), and (5) Vendor replica holds the complete log from genesis (full history backfill, as Vendor is a late joiner).Scenario-specific invariants prevent the RCCV-extension scenario from accidentally omitting protocol steps not covered by universal checks. |
RCCV-Handoff Scenario (C1 transfers case ownership to C2; C2 invites Vendor)
Trigger: Scenario Start — rccv-handoff
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-14-001 |
MUST | The RCCV-handoff scenario MUST involve four actor identities — Reporter, Coordinator1 (C1), Coordinator2 (C2), and Vendor — each with its own isolated DataLayer; the CaseActor MUST run as a separate actor identity with its own container and DataLayer. RCCV-handoff demonstrates the case-ownership-transfer flow between two coordinators. C1 starts as CASE_OWNER, then transfers ownership to C2 via TRIG-11-001/002. The five-container topology (Reporter, C1, C2, Vendor, CaseActor) reuses the existing docker-compose-multi-actor.yml services (vendor→C1, coordinator→C2, vendor2→Vendor). |
Refines: DEMOMA-01-001 Refines: DEMOMA-13-001 |
DEMOMA-14-002 |
MUST | C1 MUST receive the Reporter's report (RM→RECEIVED), validate it (RM→VALID), and engage the case (RM→ACCEPTED), holding CVDRole.CASE_OWNER until it completes the ownership transfer to C2. C1 acts as the initial entry point for Reporter's report and establishes the case before delegating ownership. This mirrors the RVCV-handoff pattern (DEMOMA-10) except the initial owner is a Coordinator, not a Vendor. ECA DetailsPreconditions - All five containers running (Reporter, C1, C2, Vendor, CaseActor); all actors seeded and cross-registered. Steps 1. [reporter] POST submit-report trigger to C1 → C1 creates VulnerabilityCase; RM→RECEIVED on C1 2. [demo-script] POST validate-report and engage-case triggers on C1 → C1 RM→VALID then RM→ACCEPTED; C1 holds CVDRole.CASE_OWNER Postconditions - C1 holds CVDRole.CASE_OWNER and case attributed_to=C1. |
Refines: DEMOMA-13-002 |
DEMOMA-14-003 |
MUST | C1 MUST invite C2 to the case (CVDRole.COORDINATOR), and C2 MUST accept. C2 must be a known participant before C1 can offer ownership transfer. The invite-accept flow establishes C2's presence in the case ledger. ECA DetailsPreconditions - C1 is CASE_OWNER; case attributed_to=C1. Steps 1. [c1] POST invite-actor-to-case trigger targeting C2 (CVDRole.COORDINATOR) → C1 sends Invite activity to CaseActor; CaseActor forwards to C2 2. [c2] POST accept-case-invite trigger → C2 joins the case (active) with CVDRole.COORDINATOR Postconditions - C2 is a participant; CVDRole.COORDINATOR; case attributed_to=C1. |
Refines: DEMOMA-14-002 |
DEMOMA-14-004 |
MUST | C1 MUST offer case ownership transfer to C2 (TRIG-11-001) and C2 MUST accept (TRIG-11-002). This is the core RCCV-handoff invariant. The ownership-transfer trigger pair (TRIG-11-001/002) is the same mechanism as RVCV-handoff (DEMOMA-10-006/007) and must be validated end-to-end. ECA DetailsPreconditions - C2 is a participant; case attributed_to=C1. Steps 1. [c1] POST offer-case-ownership-transfer trigger targeting C2 → C1 sends Offer activity to C2's inbox via CaseActor 2. [c2] POST accept-case-ownership-transfer trigger referencing the Offer → case attributed_to=C2 on C1's and C2's DataLayers; C2 holds CVDRole.CASE_OWNER; C1 holds CVDRole.COORDINATOR Postconditions - case attributed_to=C2 on C1's and C2's DataLayers. - C2 holds CVDRole.CASE_OWNER; C1 holds CVDRole.COORDINATOR. |
Refines: DEMOMA-14-003 |
DEMOMA-14-005 |
MUST | C2 (the new CASE_OWNER) MUST invite Vendor to the case (NOT C1), and Vendor MUST accept. This invariant confirms that the new CASE_OWNER (C2) has the authority to extend the case to additional actors after the handoff, and that the four-actor coordination path works end-to-end. ECA DetailsPreconditions - case attributed_to=C2; C2 is CASE_OWNER. Steps 1. [c2] POST invite-actor-to-case trigger for Vendor → C2 sends Invite activity to CaseActor; CaseActor forwards to Vendor 2. [vendor] POST accept-case-invite trigger directed to CaseActor → Vendor joins the case (active); Accept delivered to CaseActor inbox Postconditions - Vendor is a participant; all five actors have case replicas. |
Refines: DEMOMA-14-004 |
DEMOMA-14-006 |
MUST | After Vendor joins, all four actor containers (Reporter, C1, C2, Vendor) MUST converge to the same case ledger state (LedgerFanout invariant). Vendor MUST receive the complete ledger backfill from genesis. Late-joiner backfill is a shared invariant across RVCV-extension, RVCV-handoff, and RCCV-handoff. Verifying it here ensures the ownership-transfer workflow does not break LedgerFanout convergence. |
Refines: DEMOMA-10-007 |
DEMOMA-14-007 |
MUST | Vendor MUST advance through the full fix lifecycle: CS.VFd (fix ready) and CS.VFD (fix deployed). End-to-end completion is required to exercise the full CVD coordination workflow in the RCCV-handoff variant. |
Refines: DEMOMA-11-006 |
DEMOMA-14-008 |
MUST | The RCCV-handoff implementation MUST ship a dedicated CLI command (vultron-demo rccv-handoff) wired into vultron/demo/cli.py and registered in the DEMOS list. The command MUST expose --reporter-url, --c1-url, --c2-url, --vendor-url, --case-actor-url, --reporter-id, --c1-id, --c2-id, --vendor-id, --case-actor-id, and --skip-health-check options with corresponding environment variable bindings.CLI consistency and Docker-entrypoint compatibility require the DEMO=rccv-handoff env var to map to vultron-demo rccv-handoff (demo-entrypoint.sh forwards the DEMO value directly). |
Refines: DEMOMA-04-002 |
DEMOMA-14-009 |
MUST | The RCCV-handoff implementation MUST ship a dedicated per-scenario case-ledger invariant test file (test/ci/invariants/test_rccv_handoff_invariants.py) built exclusively on the shared invariant library (per DEMOMA-12-008 pattern).Scenario-specific invariants prevent the RCCV-handoff scenario from accidentally omitting protocol steps not covered by universal checks. The Vendor-as-late-joiner invariant is shared with RVCV-extension (DEMOMA-10) and RCCV-extension (DEMOMA-13). |
Refines: DEMOMA-12-008 |
DEMOMA-14-010 |
MUST | C2 MUST be a participant with CVDRole.COORDINATOR before the ownership transfer is initiated in the RCCV-handoff scenario. C2 must be a known case participant before C1 can offer ownership transfer; the invite-accept flow in DEMOMA-14-003 establishes C2's presence in the ledger. |
|
DEMOMA-14-011 |
MUST | After the RCCV-handoff ownership transfer, the case's attributed_to MUST reflect C2 on C1's DataLayer, C2's DataLayer, and the CaseActor's canonical ledger. attributed_to reflects the current CASE_OWNER; all replicas must converge on the updated value after the ownership transfer completes. |
|
DEMOMA-14-012 |
MUST | After the RCCV-handoff ownership transfer, C1's CVDRole MUST become CVDRole.COORDINATOR (no longer CASE_OWNER). This is the defining role-change outcome of the handoff; C1 retains participation but surrenders ownership authority. |
|
DEMOMA-14-013 |
MUST | The Vendor's Accept activity MUST be delivered to the CaseActor so AcceptInviteActorToCaseBT runs and the join is committed to the canonical ledger (PCR-08-008).Accept must reach the CaseActor, not just the inviting actor, so the join is recorded in the authoritative ledger. |
|
DEMOMA-14-014 |
MUST | All participants MUST report publication (CS.VFDPx or CS.VFDPxa) and the embargo MUST terminate (EM.EXITED) in the RCCV-handoff scenario. Public disclosure triggers embargo teardown via ThreatTerminationBranchNode; both outcomes must be confirmed to validate the end-to-end CS.P path. |
|
DEMOMA-14-015 |
MUST | All participants MUST close the case (RM.CLOSED) in the RCCV-handoff scenario. RM.CLOSED for all participants is the terminal acceptance criterion for the full CVD lifecycle in the RCCV-handoff scenario. |
|
DEMOMA-14-016 |
MUST | The RCCV-handoff invariant test file MUST assert the following scenario-specific checks in addition to universal library checks: (1) validate_report event present in case-actor log, (2) invite_actor_to_case present at least twice (C2 invitation + Vendor invitation), (3) accept_invite_actor_to_case present at least twice (C2 accept + Vendor accept), (4) close_case present, (5) CS transitions VFd and VFD observed in add_participant_status_to_participant entries for the Vendor actor, and (6) Vendor replica holds the complete log from genesis (late-joiner full-history backfill).Scenario-specific invariants prevent the RCCV-handoff scenario from accidentally omitting protocol steps not covered by universal checks. |
Demo Helper Preconditions
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-15-001 |
MUST | 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.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 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. |
Refines: DEMOMA-06-002 Refines: DEMOMA-12-005 |
DEMOMA-15-002 |
MUST | 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.A VENDOR-only actor stops at VFd and cannot advance to VFD (CSB-15-002); requiring DEPLOYER makes the failure mode unambiguous when the wrong actor ID is passed. |
|
DEMOMA-15-003 |
MUST | Each demo helper (verify_fix_ready and verify_fix_deployed) MUST enforce its role precondition at runtime, raising AssertionError with the actor ID and its actual roles if the assertion fails.Runtime enforcement surfaces caller errors immediately with a clear diagnostic rather than a confusing downstream VFD state-check failure. |
Per-Scenario Required Semantic Types for the Invariant Harness
Each scenario defines a minimum set of protocol semantic types that MUST appear in the authoritative (case-actor) case-ledger log for a complete, correct run. These lists are implemented as the per-scenario _XXX_EXPECTED_SEMANTIC_TYPES constants in the corresponding test/ci/invariants/test_XXX_invariants.py file and are checked by Invariant 5 (test_invariant_5_expected_semantic_types_present). They complement, and do not replace, the scenario-specific invariant test functions that use check_semantic_type_count for events that must appear multiple times.
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-16-001 |
MUST | The five universal semantic types — validate_report, add_participant_status_to_participant, close_case, add_note_to_case, and engage_case — MUST appear at least once in the authoritative case-actor log for every multi-actor demo scenario.These five types are the minimal coverage shared by all scenarios: report validation, RM engagement (VALID → ACCEPTED), participant state tracking, case closure, and at least one note (the add-note trigger posts a note as one inline Add(Note), committed as add_note_to_case). A scenario missing any of the five has not completed the basic CVD lifecycle. engage_case is as universal as validate_report: emission lives in the shared demo helper layer, not at scenario call sites, so no scenario file names the trigger itself (CONCERN-2243). Paths: see notes/demo-ci-invariants.md. |
Refines: DEMOMA-03-002 |
DEMOMA-16-002 |
MUST | In addition to the universal types (DEMOMA-16-001), the rv scenario MUST verify that its expected-semantic-types list is restricted to types that the rv scenario actually produces. rv does not include an invite_actor_to_case phase; the Reporter joins by submitting a report, not via an invitation. |
Refines: DEMOMA-16-001 Refines: DEMOMA-06-002 |
DEMOMA-16-003 |
MUST | The RVV scenario expected-semantic-types list MUST include invite_actor_to_case and accept_invite_actor_to_case in addition to the universal types (DEMOMA-16-001), because Vendor1 invites Vendor2 into the case and Vendor2 explicitly accepts via the accept-case-invite trigger. |
Refines: DEMOMA-16-001 Refines: DEMOMA-09-002 |
DEMOMA-16-004 |
MUST | The RVCV-extension scenario expected-semantic-types list MUST include invite_actor_to_case, offer_case_participant, accept_invite_actor_to_case, and accept_actor_recommendation in addition to the universal types (DEMOMA-16-001), because Vendor1 invites Coordinator, Coordinator uses the ADR-0026 suggest-actor flow (offer_case_participant) to propose Vendor2, Vendor1 approves the recommendation (accept_actor_recommendation), CaseActor converts the approval into an invitation, and Vendor2 explicitly accepts via the accept-case-invite trigger. |
Refines: DEMOMA-16-001 Refines: DEMOMA-10-001 |
DEMOMA-16-005 |
MUST | The RVCV-handoff scenario expected-semantic-types list MUST include invite_actor_to_case, accept_invite_actor_to_case and accept_case_ownership_transfer in addition to the universal types (DEMOMA-16-001), because Vendor1 invites Coordinator, the Coordinator accepts the invitation and then accepts the ownership transfer to become the new CASE_OWNER (TRIG-11-002, CM-21-007), and Coordinator then invites Vendor2 who also accepts.In addition to the semantic-type coverage, RVCV-handoff is the scenario planned to exercise the non-linear RM path R → I → V (tentative rejection then acceptance, implementation tracked in #2050): Vendor1 will initially invalidate the report (RI), then re-validate after the Reporter provides clarifying information via a case note. This RM state-transition path will be verified by demo_check assertions in the scenario script, not by semantic_type presence in the invariant harness, because R → I → V produces the same validate_report ledger event as the linear R → V path. |
Refines: DEMOMA-16-001 Refines: DEMOMA-11-001 |
DEMOMA-16-006 |
MUST | The RCCV-handoff scenario expected-semantic-types list MUST include invite_actor_to_case, accept_invite_actor_to_case and accept_case_ownership_transfer in addition to the universal types (DEMOMA-16-001), because C1 invites C2, C2 accepts the invitation and then accepts the ownership transfer to become the new CASE_OWNER (TRIG-11-002, CM-21-007), and C2 then invites the Vendor who also accepts. |
Refines: DEMOMA-16-001 Refines: DEMOMA-14-009 |
DEMOMA-16-007 |
MUST | The RCV scenario expected-semantic-types list MUST include invite_actor_to_case and accept_invite_actor_to_case in addition to the universal types (DEMOMA-16-001), because the Coordinator invites the Vendor into the case and the Vendor explicitly accepts via the accept-case-invite trigger. The Reporter is seated at case creation and is not invited (DEMOMA-12-003). |
Refines: DEMOMA-16-001 Refines: DEMOMA-12-008 |
DEMOMA-16-008 |
MUST | The per-scenario expected-semantic-types list in each test/ci/invariants/test_XXX_invariants.py file MUST be kept in sync with the corresponding DEMOMA-16-XXX spec requirement; when a new scenario phase is added or removed, both the spec and the test list MUST be updated in the same PR.The test list is the code-level implementation of the spec requirement. Allowing them to drift reintroduces the silent-failure risk that motivated issue #1649. |
Refines: DEMOCI-04-004 |
DEMOMA-16-009 |
MUST | The RCVCV scenario expected-semantic-types list MUST include invite_actor_to_case, offer_case_participant, accept_invite_actor_to_case, and accept_actor_recommendation in addition to the universal types (DEMOMA-16-001).*RCVCV is the first scenario with two independent coordinators and two independent vendor actors. C1 directly invites V1 and C2; C2 suggests V2 via ADR-0026 (offer_case_participant → C1 approves via accept_actor_recommendation → CaseActor invite → V2 accept); all three vendor/coordinator invitees issue explicit accepts. Verifying these semantic types confirms the full ADR-0026 suggest-actor path and the multi-vendor invitation chain are exercised end-to-end. |
|
In addition to semantic-type coverage, RCVCV is the scenario planned to exercise the non-linear RM path A → D → A (accepted, then deferred, then re-accepted, implementation tracked in #2050): V1 will accept the case, defer due to competing priorities (RD message), then resume and engage again (RV message). This RM state-transition path will be verified by demo_check assertions in the scenario script. The defer-case and engage-case trigger endpoints both support this path without new BT nodes, since the RM state machine already declares ACCEPT: DEFERRED → ACCEPTED as a valid transition.* |
Refines: DEMOMA-16-001 Refines: DEMOMA-19-001 |
||
DEMOMA-16-010 |
MUST | The RCCV-extension scenario expected-semantic-types list MUST include invite_actor_to_case, offer_case_participant, accept_invite_actor_to_case, and accept_actor_recommendation in addition to the universal types (DEMOMA-16-001), because Coordinator1 invites Coordinator2, Coordinator2 uses the ADR-0026 suggest-actor flow (offer_case_participant) to propose the Vendor, Coordinator1 approves the recommendation (accept_actor_recommendation), CaseActor converts the approval into an invitation, and the Vendor explicitly accepts via the accept-case-invite trigger. |
Refines: DEMOMA-16-001 Refines: DEMOMA-13-001 |
DEMOMA-16-011 |
MUST | The RCV-reject scenario expected-semantic-types list MUST include invite_actor_to_case and reject_invite_actor_to_case in addition to the universal types (DEMOMA-16-001), because the Coordinator invites the Vendor and the Vendor sends Reject(Invite(Actor, VulnerabilityCaseStub)) (an invitation-layer rejection), which triggers RejectInviteActorToCaseReceivedUseCase on the CaseActor and produces the reject_invite_actor_to_case ledger entry. The Vendor never sends Accept(Invite(Actor, VulnerabilityCaseStub)), so accept_invite_actor_to_case does NOT appear in this scenario. |
Refines: DEMOMA-16-001 Refines: DEMOMA-27-002 |
DEMOMA-16-012 |
MUST | In the RCVCV scenario, the case-actor log MUST record invite_actor_to_case at least three times (C1 invites V1, C1 invites C2, and CaseActor invites V2 via the ADR-0026 path) and offer_case_participant at least once (C2 suggests V2 to C1 via the ADR-0026 suggest-actor flow).These counts confirm that the multi-party invitation and suggest-actor flows were exercised end-to-end, not just that the semantic types appear at least once. |
|
DEMOMA-16-013 |
MUST | In the RCVCV scenario, the case-actor log MUST record accept_actor_recommendation at least once (C1 approves C2's recommendation) and accept_invite_actor_to_case at least three times (V1, C2, and V2 each accept their respective invitations).These counts verify that every actor who received an invitation explicitly accepted, and that the ADR-0026 approval path was exercised. |
|
DEMOMA-16-015 |
MUST | The VC scenario expected-semantic-types list MUST include invite_actor_to_case and accept_invite_actor_to_case in addition to the universal types (DEMOMA-16-001), because Vendor invites Coordinator into the case as CVDRole.OBSERVER and Coordinator explicitly accepts via the accept-case-invite trigger.Vendor is CASE_OWNER from self-reporting, so Vendor drives the direct invite-actor-to-case trigger (no suggest/approve chain). Coordinator accepts and becomes a signatory by accepting the embargo. No offer_case_participant or accept_actor_recommendation events are expected because there is no second-level recommendation in this two-actor scenario. |
Refines: DEMOMA-16-001 Refines: DEMOMA-25-001 |
DEMOMA-16-016 |
MUST | The RCV-embargo scenario expected-semantic-types list MUST include invite_actor_to_case, accept_invite_actor_to_case, invite_to_embargo_on_case, accept_invite_to_embargo_on_case, activate_embargo_on_case and remove_embargo_event_from_case in addition to the universal types (DEMOMA-16-001), because the Coordinator invites the Vendor and the Vendor accepts, the Reporter's embargo proposal and its relayed invitations are committed, the Vendor accepts it, the owner activates it (VAM-05-008), and the Coordinator terminates the embargo.The embargo semantic types are the ones the CASE_MANAGER's commit path emits (DEMOMA-20-006); the harness imports them from that code, so a rename there fails the harness rather than leaving it checking a stale string. |
Refines: DEMOMA-16-001 Refines: DEMOMA-20-006 |
DEMOMA-16-017 |
MUST | The RCVV-embargo scenario expected-semantic-types list MUST include invite_actor_to_case, accept_invite_actor_to_case, invite_to_embargo_on_case, accept_invite_to_embargo_on_case, activate_embargo_on_case and remove_embargo_event_from_case in addition to the universal types (DEMOMA-16-001), because the Coordinator invites both Vendors and they accept, the Reporter's and Vendor1's embargo proposals and their relayed invitations are committed, the other participants accept them, the owner activates each revision (VAM-05-008), and the embargo is torn down when the Reporter reports publication.The embargo semantic types are the ones the CASE_MANAGER's commit path emits (DEMOMA-21-007); the harness imports them from that code, so a rename there fails the harness rather than leaving it checking a stale string. |
Refines: DEMOMA-16-001 Refines: DEMOMA-21-007 |
DEMOMA-16-014 |
MUST | The RCVD scenario expected-semantic-types list MUST include invite_actor_to_case and accept_invite_actor_to_case in addition to the universal types (DEMOMA-16-001), because the Coordinator invites Reporter, Vendor, and Deployer into the case, and all three explicitly accept via the accept-case-invite trigger.RCVD exercises a four-actor coordination chain with one full invitation cycle per non-case-owner actor; invite_actor_to_case and accept_invite_actor_to_case confirm the invitation/accept path was exercised for each. Unlike scenarios with ADR-0026 suggest-actor flows, RCVD uses only direct case-owner invitations. |
Refines: DEMOMA-16-001 Refines: DEMOMA-24-001 |
In-Process Fuzz Simulation Scenario
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-18-001 |
MUST | The fuzz simulation scenario MUST run entirely in-process with no HTTP servers, containers, or network I/O required; each actor MUST have its own isolated in-memory DataLayer. The scenario is a simulation tool for exploring the protocol state space probabilistically. Container setup would make it unsuitable for local development and CI smoke-testing. Derived from issue #1178 planning decisions. |
Refines: DEMOMA-01-001 |
DEMOMA-18-002 |
MUST | The fuzz simulation scenario MUST support a configurable number of iterations N (default 10); each iteration MUST start with fresh in-memory DataLayers for all actors (no state carried across iterations). Each iteration is an independent probabilistic sample of the protocol state space. Shared state would cause later iterations to depend on earlier ones, undermining the exploration goal. |
|
DEMOMA-18-003 |
MUST | The fuzz simulation scenario MUST use STOCHASTIC call-out point bundles for all actors so that each call-out decision is drawn from the fuzzer node probability distribution rather than a deterministic default. The scenario's purpose is probabilistic state-space exploration. DETERMINISTIC bundles would produce an identical path on every iteration, defeating the simulation goal. |
Refines: DEMOMA-18-001 |
DEMOMA-18-004 |
MUST | The fuzz simulation scenario MUST simulate in-process message delivery between actors by directly invoking the receiving actor's dispatcher with the emitted activity; no HTTP, ASGIEmitter, or real network transport is permitted. Direct DataLayer injection allows the simulation to run without network infrastructure while still exercising the full trigger-endpoint→BT→outbox→peer-inbox cascade. |
Refines: DEMOMA-18-001 |
DEMOMA-18-005 |
MUST | Each iteration MUST log structured output at INFO level including: the iteration number, each call-out point node name and its SUCCESS/FAILURE outcome, and the final RM/EM/CS state per actor. Per-run logging is the primary diagnostic output of the scenario; it allows users to observe which protocol paths were exercised across iterations. |
|
DEMOMA-18-006 |
MUST | The fuzz simulation scenario MUST be accessible via the vultron demo fuzz CLI sub-command, accepting at minimum an --iterations N option; the default number of iterations MUST be documented in the CLI help text.Consistent with the existing vultron demo CLI structure (DC-01-001 through DC-01-005); the fuzz sub-command makes the scenario discoverable alongside other demo commands. |
Refines: DEMOMA-18-001 |
DEMOMA-18-007 |
MUST | Existing deterministic demo scenarios MUST NOT be modified by the fuzz simulation scenario; the fuzz scenario MUST NOT import from or depend on any existing vultron/demo/scenario/ module.The fuzz scenario is a standalone simulation tool; coupling it to deterministic scenario files would break their repeatability and make the fuzz scenario harder to isolate. |
|
DEMOMA-18-008 |
MUST | A unit test in test/demo/ MUST verify that the fuzz simulation scenario completes a single iteration without raising an exception; the test MUST run with a seeded RNG where possible.The test provides a regression guard confirming the scenario still runs after code changes, independent of the specific protocol path taken. |
Refines: DEMOMA-18-001 |
Demo Code Reuse and DRY
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-17-001 |
MUST | Before writing a second use of a pattern in a scenario file under vultron/demo/scenario/, the pattern MUST be extracted to vultron/demo/helpers/ first. Scenario files MUST NOT copy-paste function bodies, polling loops, verification blocks, or any other logical unit from an existing scenario file.Every scenario written by copying the previous one propagates latent bugs alongside valid patterns. Issue #1632 documented residual duplication remaining after PR #1629 reactively extracted five helper modules from five already-written scenarios. The rule enforces extraction-first to prevent recurrence. |
Refines: CS-22-001 — MUST-level specialisation of the project-wide SHOULD rule for demo scenario files Refines: DEMOMA-03-001 — acceptance test scenario files are also subject to this rule |
DEMOMA-17-002 |
MUST NOT | A change that consolidates demo code (extracting or merging helpers, phases or scenario bodies under DEMOMA-17-001) MUST NOT change what any demo scenario passes or fails on. A verdict changed by a consolidation PR MUST instead be made in its own change, with its own issue. Consolidation is behaviour-preserving. A PR that also loosens or tightens a check hides the verdict change inside a refactor, where nobody is looking for it (#3905 changed what a demo passes on inside a consolidation and had no issue). |
Refines: DEMOMA-17-001 |
RCVCV 5-Party Scenario (Reporter + Coordinator1 + Vendor1 + Coordinator2 + Vendor2)
Specifies the RCVCV multi-actor demo scenario: a 5-party CVD case with Reporter (F), Coordinator1 (C1), Vendor1 (V1), Coordinator2 (C2), and Vendor2 (V2). C1 receives the report and creates the case. C1 invites V1 and C2. C2 suggests V2 to C1 via the ADR-0026 suggest-actor flow; C1 approves; CaseActor invites V2. V1 holds only CVDRole.VENDOR (reaches VFd, fix-ready, but has no deploy path). V2 holds both CVDRole.VENDOR and CVDRole.DEPLOYER (full VFD path). V1 triggers CS.P first, causing CaseActor to initiate embargo teardown via ThreatTerminationBranchNode. This scenario is the first to exercise: (a) two independent coordinators, (b) two vendor actors with different role sets, (c) embargo teardown triggered by a non-CASE_OWNER vendor participant's publication.
Trigger: Scenario Start — rcvcv
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-19-001 |
MUST | The RCVCV scenario MUST add an actor6 service to docker/docker-compose-multi-actor.yml, configured identically to the existing actor5 service (same *actor-service anchor, port pattern, data volume) with VULTRON_SERVER__BASE_URL=http://actor6:7999/api/v2/, VULTRON_ACTOR_ID=http://actor6:7999/api/v2/actors/vendor-deployer, VULTRON_ACTOR__DEFAULT_CASE_ROLES=["vendor","deployer"], and VULTRON_SEED_CONFIG=/app/docker/seed-configs/seed-actor6.yaml.The RCVCV scenario requires five independent actor containers — one per CVD-role actor — to force all inter-actor communication over the network wire protocol (DEMOMA-01-001). The existing four non-CaseActor containers are insufficient; a sixth container ( actor6) carries V2 (VendorDeployer). Keeping one actor per container preserves the architectural isolation invariant that prevents intra-container data-layer shortcuts (ADR-0042). |
Refines: DEMOMA-01-001 Refines: DEMOMA-02-001 |
DEMOMA-19-002 |
MUST | A seed_containers_rcvcv function MUST be added to vultron/demo/helpers/seeding.py accepting five DataLayerClient arguments (reporter_client, c1_client, v1_client, c2_client, v2_client) and five optional deterministic actor ID arguments.The 5-actor cross-registration ensures that every actor can locate and deliver activities to every other actor without runtime DNS surprises. The N×(N-1) pattern is established by all existing multi-actor seed functions (seed_containers_rvv, seed_containers_rvcv, seed_containers_rccv). |
Refines: DEMOMA-19-001 Refines: DEMOMA-02-002 |
DEMOMA-19-003 |
MUST | The RCVCV scenario file MUST be implemented at vultron/demo/scenario/rcvcv_demo.py.The phased structure mirrors the RCCV-extension scenario (the closest prior template) while adding V1's vendor-only VFd path and V2's full VFD path. Having V1 publish first exercises the ThreatTerminationBranchNode on a non-CASE_OWNER vendor participant, a path not covered by any prior scenario (DEMOMA-07-003 correction). |
Refines: DEMOMA-01-001 Refines: DEMOMA-03-002 Refines: DEMOMA-17-001 |
DEMOMA-19-004 |
MUST | In the RCVCV scenario, V1 MUST be assigned CVDRole.VENDOR only (no DEPLOYER role) and V2 MUST be assigned both CVDRole.VENDOR and CVDRole.DEPLOYER.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 precondition guards: verify_fix_ready requires VENDOR; verify_fix_deployed requires 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. |
Refines: DEMOMA-15-001 Refines: DEMOMA-19-003 |
DEMOMA-19-005 |
MUST | In the RCVCV scenario, V1 MUST publish before V2; after V1 sends its participant status update indicating CS.P, the scenario MUST wait for CaseActor to process that update and emit the embargo teardown (EM.EXITED) before V2 publishes. Having V1 publish first exercises the ThreatTerminationBranchNode code path where a VENDOR participant (not the CASE_OWNER) triggers embargo teardown — the scenario path corrected in DEMOMA-07-003. Waiting for EM.EXITED before V2 publishes avoids a timing race where the teardown-triggered publication arrives concurrently with V2's publication and produces a non-deterministic ledger order. |
Refines: DEMOMA-07-003 Refines: DEMOMA-19-003 |
DEMOMA-19-006 |
MUST | The RCVCV scenario MUST be registered as the rcvcv sub-command in vultron/demo/cli.py, consistent with the naming convention of existing scenario commands (rv, rvv, rcv, rvcv, rccv).The CLI is the single entry point for all demo scenarios (DC-01-001). Omitting the sub-command leaves the scenario unreachable from vultron demo rcvcv and from the CI matrix DEMO variable override. |
Refines: DEMOMA-01-001 |
DEMOMA-19-007 |
MUST | The RCVCV scenario MUST be registered with the scenario registry (DEMOCI-11-001), so that its generated .github/demo-scenarios.json entry (DEMOCI-11-004) adds it to the CI matrix, and actor6 MUST be defined in docker/docker-compose-multi-actor.yml alongside the existing five actor services.CI coverage of all demo scenarios is required by DEMOCI-04-001. The RCVCV scenario introduces the actor6 container; CI starts the whole multi-actor compose file, so defining the service there is what makes it available to the run. Scenario-role names in devlogs match the names used in scenario code, making log archaeology easier. |
Refines: DEMOCI-04-001 Refines: DEMOMA-19-001 |
DEMOMA-19-008 |
MUST | An invariant harness file MUST be created at test/ci/invariants/test_rcvcv_invariants.py with _DEMO_NAME = "rcvcv", a _CHAIN_ACTORS list of scenario-role actor names (reporter, c1, v1, c2, v2, case-actor), and _RCVCV_EXPECTED_SEMANTIC_TYPES per DEMOMA-16-009.The invariant harness is the CI-level acceptance test for the scenario. The three scenario-specific count checks verify that the multi-party invitation and suggest-actor flows were exercised end-to-end. The per-actor chain checks confirm ledger contiguity for all five actor devlogs. Derived from the RCCV-extension invariant harness as the closest prior template. |
Refines: DEMOCI-04-001 Refines: DEMOMA-16-009 Refines: DEMOMA-19-003 |
DEMOMA-19-009 |
MUST | The RCVCV scenario MUST exercise the ADR-0026 suggest-actor flow for V2's onboarding: C2 posts suggest-actor-to-case, CaseActor forwards an Offer(CaseParticipant) to C1, C1 posts accept-actor-recommendation, CaseActor sends Invite to V2, and V2 posts accept-case-invite.The ADR-0026 flow is the accepted pattern for coordinator-to-case actor suggestion (ADR-0026). The RCVCV scenario is the first to exercise C2 (a non-CASE_OWNER coordinator) suggesting a vendor; prior scenarios (RCCV-extension) exercised C2 suggesting an existing vendor, but RCVCV adds V2 as a new participant. The no-mail-carrying rule (DEMOMA-03-003) requires that the demo orchestrator never post directly to another actor's inbox; the invite must arrive via the normal routing path and be detected by polling. |
Refines: DEMOMA-03-003 Refines: DEMOMA-19-003 |
DEMOMA-19-010 |
MUST | A seed-actor6.yaml seed config MUST be created in docker/seed-configs/ with local_actor.id=http://actor6:7999/api/v2/actors/vendor-deployer, local_actor.name=VendorDeployer, and a peers list covering the five other actors (reporter, vendor, coordinator, actor5, case-actor).The seed config bootstraps actor6's identity and peer registry at container startup, consistent with the pattern used by all other actor containers. |
|
DEMOMA-19-011 |
MUST | The demo-runner service MUST add actor6 to its depends_on list and expose VULTRON_VENDOR_DEPLOYER_BASE_URL=http://actor6:7999/api/v2 in its environment block.Without actor6 in depends_on, the demo runner may start before actor6 is healthy; without the environment variable, the scenario cannot locate V2. |
|
DEMOMA-19-012 |
MUST | The seed_containers_rcvcv function MUST create five local actors: Reporter (Person), Coordinator1 (Organization), Vendor1 (Organization), Coordinator2 (Organization), and VendorDeployer (Organization).Each of the five scenario participants requires a typed local actor record in its container's DataLayer before cross-registration. |
|
DEMOMA-19-013 |
MUST | The seed_containers_rcvcv function MUST register every actor as a known peer on each of the other four containers (N×(N-1) = 20 cross-registrations total), following the same Phase 1 / Phase 2 pattern as seed_containers_rccv.Full cross-registration ensures every actor can locate and deliver activities to every other actor without runtime DNS surprises. |
|
DEMOMA-19-014 |
MUST | The RCVCV scenario MUST implement the following phases in order: report_submission, c2_suggests_v2, sync_verification, notes_exchange, fix_lifecycle, publication, case_closure, and dump_case_ledgers. The phased structure mirrors the RCCV-extension scenario (the closest prior template) while adding V1's vendor-only VFd path and V2's full VFD path. Phase ordering reflects temporal protocol constraints. ECA DetailsPreconditions - All six containers running (Reporter, C1, V1, C2, V2, CaseActor); all actors seeded and cross-registered. Steps 1. [reporter] report_submission phase — POST submit-report trigger to C1, which then invites V1 and C2 → C1 creates the VulnerabilityCase and holds CVDRole.CASE_OWNER; V1 and C2 accept their invitations 2. [c2] c2_suggests_v2 phase — drive the ADR-0026 suggest-actor flow for V2's onboarding (DEMOMA-19-009) → C2 posts suggest-actor-to-case; C1 posts accept-actor-recommendation; CaseActor invites V2; V2 posts accept-case-invite 3. [demo-script] sync_verification phase — poll every replica for the participant set → all five participants are present on every replica 4. [demo-script] notes_exchange phase — drive add-note-to-case → add_note_to_case is recorded in the case-actor log 5. [demo-script] fix_lifecycle phase — advance V1 to VFd and V2 to VFD, calling the verification helpers named by DEMOMA-19-015 → verify_fix_ready passes for V1 and V2; verify_fix_deployed passes for V2 only, because V1 holds no DEPLOYER role 6. [demo-script] publication phase — V1 publishes, then V2 (DEMOMA-19-005) → CaseActor emits the embargo teardown (EM.EXITED) after V1's CS.P update and before V2 publishes 7. [demo-script] case_closure phase — close the case on every participant → RM.CLOSED for every participant in the CASE_MANAGER's store; each participant's replica ends at the entries recording its own closure (CM-23-004) 8. [demo-script] dump_case_ledgers phase → one case-ledger JSONL artifact per actor under devlogs/rcvcv/ Postconditions - EM.EXITED after V1 reaches CS.P (DEMOMA-19-005); V1 at VFd and V2 at VFD (DEMOMA-19-015); every participant at RM.CLOSED, which the universal Invariant 7 check ( test_invariant_7_log_terminates_all_rm_closed) asserts for every scenario rather than this group. Nothing beyond those is restated here: a postcondition is a projection of the group's normative statements, and asserting a state no statement carries would make this block the only place that requirement exists (MS-16-002). |
|
DEMOMA-19-015 |
MUST | The fix lifecycle phase MUST call verify_fix_ready for both V1 and V2 after they each advance to VFd, and MUST call verify_fix_deployed for V2 only after V2 advances to VFD.V1's vendor-only role set (stops at VFd) and V2's vendor-deployer role set (reaches VFD) are the key differentiators of the RCVCV scenario. |
|
DEMOMA-19-016 |
MUST | verify_fix_ready MUST NOT be called with a DEPLOYER-only actor ID; verify_fix_deployed MUST NOT be called with a VENDOR-only (no DEPLOYER) actor ID such as V1.Calling the wrong helper with the wrong actor masks future regressions where the BT gate is weakened; the precondition guards surface the error immediately. |
|
DEMOMA-19-017 |
MUST | The verify_publicly_disclosed check MUST be called after both V1 and V2 have published in the RCVCV scenario.Calling the check before both actors have published may produce a spurious failure if the second publication has not yet propagated. |
|
DEMOMA-19-018 |
MUST | The CI devlogs directory for the RCVCV run MUST use the scenario-role naming convention: reporter, c1, v1, c2, v2, case-actor (not docker service names).Scenario-role names match the names used in scenario code, making log archaeology easier than using docker service names. |
|
DEMOMA-19-019 |
MUST | The RCVCV invariant harness MUST define the universal invariant tests (Invariants 1–5) plus at minimum these scenario-specific invariant tests: invite_actor_to_case appears at least three times (C1→V1, C1→C2, CaseActor→V2), offer_case_participant appears at least once (C2 suggests V2 via ADR-0026), and accept_invite_actor_to_case appears at least three times (V1, C2, V2 each accept).The three scenario-specific count checks verify that the multi-party invitation and suggest-actor flows were exercised end-to-end. |
|
DEMOMA-19-020 |
MUST | The RCVCV invariant harness MUST load its replicas via load_devlogs(demo_name=_DEMO_NAME) from test/ci/invariants/common.py, following the same self-contained pattern as every prior scenario harness.Consistent loading via common.py ensures the harness can be run standalone without importing scenario module state. |
|
DEMOMA-19-021 |
MUST | The RCVCV scenario MUST be routed to the invariant harness file by the demo/test_file pair in its generated .github/demo-scenarios.json entry (DEMOCI-11-004); harnesses MUST NOT be registered via a conftest.py scenario mapping.The harness path is derived from the registered scenario name by convention (DEMOCI-11-003) and reaches CI only through the generated matrix; conftest.py mappings are not consulted by the CI runner. |
|
DEMOMA-19-022 |
MUST | The RCVCV scenario MUST use polling helpers (not post_to_inbox_and_wait) to deliver the invite to V2's inbox, consistent with the no-mail-carrying rule (DEMOMA-03-003).The no-mail-carrying rule requires that the demo orchestrator never post directly to another actor's inbox; the invite must arrive via the normal routing path and be detected by polling. |
RCV Embargo Scenario (Reporter + Coordinator + Vendor, deliberate termination)
Specifies the rcv-embargo demo scenario: a 3-party CVD case with Reporter (R), Coordinator (C), and Vendor (V), exercising embargo lifecycle variations (b) post-submission negotiation and (e) deliberate early termination. After report submission and case setup, R proposes an embargo (EP). Case creation has already activated the default embargo (EP-04-001), so the case is at EM.ACTIVE before R proposes and R's proposal revises that embargo (ACTIVE → REVISE). V and C accept (EA); C, as case owner, activates the revision, returning EM to ACTIVE with the revision as the active embargo and all three parties as signatories to it. The fix lifecycle then proceeds. Before the natural embargo window expires, C deliberately terminates the embargo (ET), advancing EM to EXITED. The case continues to the normal P-transition and closure sequence. This scenario is the first to exercise: (a) embargo proposal initiated after report submission (variation b), (b) the full EP→EA→ET arc (propose, activate, terminate without a further revision), (c) each party's consent row for the revised embargo passing through INVITED → AGREED (signatory to the active embargo) for all three parties.
Trigger: Scenario Start — rcv-embargo
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-20-001 |
MUST | The rcv-embargo scenario file MUST be implemented at vultron/demo/scenario/rcv_embargo_demo.py.The phased structure mirrors prior RCV/RVCV scenarios while inserting embargo lifecycle phases between report setup and fix-lifecycle. Asserting EM state at phase boundaries catches regressions where an embargo trigger silently fails and the scenario continues in an unexpected state. Variation (b) places the EP message after report submission, consistent with the protocol definition: R already has an established case before proposing embargo terms. |
Refines: DEMOMA-01-001 Refines: DEMOMA-12-001 |
DEMOMA-20-002 |
MUST | An exchange helper function demo_propose_and_activate_embargo MUST be added to vultron/demo/exchange/ accepting (reporter, coordinator, vendor, case) arguments — each an ActorSession (DEMOMA-26-001), because a trigger addresses an actor as well as its container, and case the VulnerabilityCase; it MUST execute the relayed propose-and-activate sequence (EP-09): the reporter posts trigger_propose_embargo, which sends the proposal to the CASE_MANAGER; the coordinator and the vendor each poll their own replica for the Invite(EmbargoEvent) the CASE_MANAGER relays to them and post trigger_accept_embargo — the coordinator's, as case owner, activates the embargo and the vendor's records its consent; the helper then polls every replica for em_state == EM.ACTIVE. No participant polls for another participant's message.Extracting the propose-and-activate sequence as a reusable exchange helper follows DEMOMA-17-001 (DRY for demo code). The three-party propose→accept→active flow is a composable unit that both rcv-embargo and rcvv-embargo scenarios require; a single helper prevents duplication and ensures both scenarios exercise identical protocol steps. |
Refines: DEMOMA-17-001 |
DEMOMA-20-003 |
MUST | An exchange helper function demo_terminate_embargo MUST be added to vultron/demo/exchange/ accepting (terminating, case) arguments — where terminating is the ActorSession of any signatory participant authorized to terminate (DEMOMA-26-001) — and posting trigger_terminate_embargo.The terminate-embargo exchange is a single-actor trigger that can be reused by any scenario exercising variation (e). Keeping it in vultron/demo/exchange/ (not inline in the scenario file) satisfies DEMOMA-17-001. Restricting assertions to the terminating actor's EM state keeps the helper side-effect-free with respect to peer-replica polling, which varies by scenario topology. |
Refines: DEMOMA-17-001 Refines: DEMOMA-20-002 |
DEMOMA-20-004 |
MUST | The rcv-embargo scenario MUST be registered as the rcv-embargo sub-command in vultron/demo/cli.py, consistent with the naming convention of existing scenario commands.The CLI is the single entry point for all demo scenarios (DC-01-001). The rcv-embargo name makes the embargo-lifecycle focus explicit and distinguishes this scenario from the base rcv scenario if one is ever added. |
Refines: DEMOMA-01-001 |
DEMOMA-20-005 |
MUST | The rcv-embargo scenario MUST be registered with in_pr_set=True (DEMOCI-11-001), so that its generated .github/demo-scenarios.json entry (DEMOCI-11-004) carries "full_suite_only": false (runs on every PR). The CI devlogs directory for the run MUST use the scenario-role naming convention: reporter, coordinator, vendor, case-actor.CI coverage of the embargo lifecycle from day one avoids the deferred-add pattern that required a later DEMOCI-06 amendment for earlier scenarios. full_suite_only: false places the scenario in the minimum PR set, consistent with the decision recorded in issue #1222 planning: default to add-now/remove-later rather than deferred adds. |
Refines: DEMOCI-04-001 |
DEMOMA-20-006 |
MUST | The rcv-embargo scenario invariant harness MUST verify that the case ledger records, in order, the semantic types the CASE_MANAGER commits for the embargo proposal, the owner's acceptance and the termination. The semantic type strings used in the invariant check MUST be the ones the CASE_MANAGER's commit path emits, read from the code rather than hand-listed here. The scenario-specific invariants confirm that all three embargo protocol messages (EP, EA, ET) were recorded in the ledger — not just that the scenario ran without crashing. The PEC consent-state check verifies that participant embargo consent transitions were emitted and recorded, which is the minimal proof that the PEC consent rows were exercised. Requiring semantic-type confirmation from a first live run prevents the harness from silently passing with wrong or missing semantic type strings. |
Refines: DEMOCI-04-001 Refines: DEMOMA-16-009 Refines: DEMOMA-20-001 |
DEMOMA-20-007 |
MUST | The rcv-embargo scenario MUST be structured with the following phases executed in order: (1) report_submission, (2) embargo_proposal, (3) fix_lifecycle, (4) embargo_termination, (5) publication, (6) case_closure, and (7) dump_case_ledgers. The phased structure mirrors prior RCV/RVCV scenarios while inserting embargo lifecycle phases between report setup and fix-lifecycle. ECA DetailsPreconditions - All four containers running (Reporter, Coordinator, Vendor, CaseActor); all actors seeded and cross-registered; no embargo in force. Steps 1. [reporter] report_submission phase — POST submit-report trigger to the Coordinator, which engages the case and invites the Vendor → the case exists on every replica; EM has no active embargo; no participant holds a consent row for an embargo 2. [reporter] embargo_proposal phase — call demo_propose_and_activate_embargo (DEMOMA-20-002): R posts trigger_propose_embargo to the CASE_MANAGER; C and V answer the relayed Invites with trigger_accept_embargo → em_state == EM.ACTIVE on every replica after the ledger fan-out and R, C and V are each a signatory to the active embargo (DEMOMA-20-009) 3. [demo-script] fix_lifecycle phase — advance the Vendor through the fix lifecycle → the fix lifecycle completes with the embargo still ACTIVE; the EM state is asserted before the next phase begins (DEMOMA-20-008) 4. [coordinator] embargo_termination phase — call demo_terminate_embargo (DEMOMA-20-003), posting trigger_terminate_embargo before the window expires → em_state == EM.EXITED on the CASE_MANAGER's canonical case (DEMOMA-20-011) 5. [demo-script] publication phase — drive the P-transition for each participant → PXA advances to Pxa on every replica 6. [demo-script] case_closure phase — close the case on every participant → RM.CLOSED for every participant in the CASE_MANAGER's store; each participant's replica ends at the entries recording its own closure (CM-23-004) 7. [demo-script] dump_case_ledgers phase → one case-ledger JSONL artifact per actor under devlogs/rcv-embargo/, recording the proposal, the owner's acceptance and the termination (DEMOMA-20-006) Postconditions - EM.EXITED after a deliberate termination (variation e, DEMOMA-20-011); every participant at RM.CLOSED, which the universal Invariant 7 check ( test_invariant_7_log_terminates_all_rm_closed) asserts for every scenario rather than this group. Nothing beyond those is restated here — see DEMOMA-19-014's postcondition for why. |
|
DEMOMA-20-008 |
MUST | After each phase of the rcv-embargo scenario, the scenario MUST assert the expected EM state before proceeding to the next phase. Phase-boundary EM assertions catch regressions where an embargo trigger silently fails and the scenario continues in an unexpected state. |
|
DEMOMA-20-009 |
MUST | After completing the propose-activate sequence, demo_propose_and_activate_embargo MUST assert em_state == EM.ACTIVE on the coordinator's case replica and that R, C and V are each a signatory to the active embargo (their consent row for it is AGREED).These post-conditions confirm that all three parties successfully enrolled in the embargo before the scenario advances. |
|
DEMOMA-20-010 |
MUST | The demo_propose_and_activate_embargo helper MUST use demo_step and demo_check for all assertions, consistent with existing exchange helpers.Using the shared assertion utilities keeps failure reporting consistent across all exchange helpers. |
|
DEMOMA-20-011 |
MUST | After delivery of trigger_terminate_embargo, demo_terminate_embargo MUST poll the CASE_MANAGER's canonical case for em_state == EM.EXITED and assert it using demo_check.The termination is adjudicated and committed by the CASE_MANAGER; the terminating actor's own replica moves only when that commit is announced (EP-09-008), so it is not the place to read the result immediately after delivery. Polling the canonical case confirms the teardown was committed before the helper returns (#3918). |
|
DEMOMA-20-012 |
MUST | demo_terminate_embargo MUST NOT assert PEC states for other participants; PEC propagation is asynchronous and belongs in scenario-level phase assertions, not in the exchange helper.Asserting peer PEC states in the exchange helper introduces a race condition; propagation timing varies by topology and scenario. |
RCVV Embargo Scenario (Reporter + Coordinator + V1 + V2, revision and accidental collapse)
Specifies the rcvv-embargo demo scenario: a 4-party CVD case with Reporter (R), Coordinator (C), Vendor1 (V1), and Vendor2 (V2), exercising embargo lifecycle variations (b) post-submission negotiation, (c) revision during active embargo (EV→ER→EJ), (f) deliberate notification delay for V2 (late invitation simulating a short-deadline vendor), and (d) accidental collapse via CS.P. After R proposes and C accepts an initial embargo (EP→EA), EM reaches ACTIVE with R, C, and V1 as signatories. C then proposes a revision (EV), V1 accepts (EJ), and EM returns to ACTIVE with updated terms. V2 is not invited until after the revision is accepted, simulating a coordinator holding off on inviting a short-deadline vendor until the embargo window is near its close. V2 accepts the case invite and becomes a signatory. Finally, R publishes (CS.P fires), triggering ThreatTerminationBranchNode which auto-terminates the embargo (EM→EXITED). This scenario is the first to exercise: (a) an embargo revision cycle (EV→ER/EJ arc), (b) a late-invitee V2 joining an active embargo (consent row INVITED→AGREED after EM.ACTIVE), (c) accidental embargo collapse via external P-event rather than deliberate termination.
Trigger: Scenario Start — rcvv-embargo
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-21-001 |
MUST | The rcvv-embargo scenario file MUST be implemented at vultron/demo/scenario/rcvv_embargo_demo.py.The phased ordering reflects the temporal constraints of the embargo protocol: revision requires an ACTIVE embargo (phase 3 must follow phase 2); V2 late-invite after revision simulates a short-deadline vendor pattern (variation f); accidental collapse via CS.P must come after both vendors have progressed through the fix lifecycle so the ledger contains a complete picture of fix-ready states before collapse. Phase-boundary assertions catch silent failures. |
Refines: DEMOMA-01-001 Refines: DEMOMA-12-001 |
DEMOMA-21-002 |
MUST | An exchange helper function demo_propose_embargo_revision MUST be added to vultron/demo/exchange/ accepting (proposing, accepting, owner, case) arguments — each of the first three an ActorSession (DEMOMA-26-001) — where owner MAY be the same session as proposing; it MUST execute the relayed revision sequence (EP-09): proposing posts trigger_propose_embargo_revision, which sends the proposal to the CASE_MANAGER (canonical em_state: ACTIVE → REVISE at the manager); accepting polls its own replica for the relayed Invite(EmbargoEvent) and posts trigger_accept_embargo, which records its consent and moves no EM state; owner posts trigger_accept_embargo, which as the case owner's answer activates the revision (canonical em_state: REVISE → ACTIVE); the helper then polls every replica for em_state == EM.ACTIVE.The revision exchange (EV→EC) is a self-contained composable unit that can be reused if future scenarios add multiple revision cycles. Accepting from REVISE state uses the same trigger_accept_embargo endpoint as accepting from PROPOSED; no new endpoint is needed. |
Refines: DEMOMA-17-001 Refines: DEMOMA-20-002 |
DEMOMA-21-003 |
MUST | The v2_late_invite phase of rcvv-embargo MUST invite V2 to the case only after the embargo revision (phase 3) is complete and em_state is confirmed ACTIVE; on receiving the case invite, V2 MUST accept the current embargo event, becoming a signatory to it (its consent row for the embargo is AGREED).Inviting V2 after the revision is complete — rather than before — is the defining characteristic of variation (f): the coordinator delays notification of a short-deadline vendor until the embargo terms are settled. Asserting that V2 is a signatory before fix_lifecycle ensures that V2 is fully enrolled in the embargo before it is accidentally collapsed by CS.P, making the ledger record unambiguous about V2's consent at time of collapse. |
Refines: DEMOMA-03-003 Refines: DEMOMA-21-001 |
DEMOMA-21-004 |
MUST | The accidental_collapse phase of rcvv-embargo MUST trigger the P-transition by calling actor_notifies_published on the reporter's client; the scenario MUST then poll for CaseActor to emit the embargo teardown (EM.EXITED via ThreatTerminationBranchNode) before asserting em_state == EM.EXITED on the coordinator's replica.Variation (d) specifically exercises the accidental/automatic embargo collapse path rather than the deliberate termination (ET) path. ThreatTerminationBranchNode fires when CS.P is detected on the CaseActor, emitting the EM termination activity automatically. Using actor_notifies_published from vultron/demo/helpers/actions.py exercises this code path end-to-end. Calling trigger_terminate_embargo directly would test variation (e) instead and would not exercise ThreatTerminationBranchNode. |
Refines: DEMOMA-21-001 Refines: DEMOMA-07-003 |
DEMOMA-21-005 |
MUST | The rcvv-embargo scenario MUST be registered as the rcvv-embargo sub-command in vultron/demo/cli.py, consistent with the naming convention of existing scenario commands.The CLI is the single entry point for all demo scenarios (DC-01-001). |
Refines: DEMOMA-01-001 |
DEMOMA-21-006 |
MUST | The rcvv-embargo scenario MUST be registered with in_pr_set=True (DEMOCI-11-001), so that its generated .github/demo-scenarios.json entry (DEMOCI-11-004) carries "full_suite_only": false (runs on every PR). The CI devlogs directory for the run MUST use the scenario-role naming convention: reporter, coordinator, vendor, vendor2, case-actor.CI coverage from day one, consistent with the decision recorded in issue #1222 planning: default to add-now/remove-later rather than deferred adds. full_suite_only: false places the scenario in the minimum PR set. |
Refines: DEMOCI-04-001 |
DEMOMA-21-007 |
MUST | The rcvv-embargo scenario invariant harness MUST verify that the case ledger records the semantic types the CASE_MANAGER commits for the embargo proposal, the owner's acceptance, the revision proposal and the owner's acceptance of the revision. The semantic type strings used in the invariant check MUST be the ones the CASE_MANAGER's commit path emits, read from the code rather than hand-listed here. The scenario-specific invariants confirm all four embargo-variation protocol paths were exercised and recorded: EP (b), EV (c), EC (c), and auto-collapse via CS.P (d). The late-joiner backfill check for V2 verifies that the ledger synchronization worked correctly when V2 joined mid-scenario after an embargo revision. Invariant (f) distinguishes accidental collapse from deliberate termination, ensuring the scenario exercised the correct code path. |
Refines: DEMOCI-04-001 Refines: DEMOMA-16-009 Refines: DEMOMA-21-001 |
DEMOMA-21-008 |
MUST | The rcvv-embargo scenario MUST be structured with the following phases executed in order: (1) report_submission, (2) embargo_proposal, (3) embargo_revision, (4) v2_late_invite, (5) fix_lifecycle, (6) accidental_collapse, (7) case_closure, and (8) dump_case_ledgers. Phase ordering reflects the temporal protocol constraints: revision requires an ACTIVE embargo; V2 late-invite simulates a short-deadline vendor held back until terms are settled. ECA DetailsPreconditions - All five containers running (Reporter, Coordinator, V1, V2, CaseActor); all actors seeded and cross-registered; V2 not yet invited. Steps 1. [reporter] report_submission phase — POST submit-report trigger to the Coordinator, which engages the case and invites V1 → the case exists on every replica with R, C and V1 as participants 2. [reporter] embargo_proposal phase — call demo_propose_and_activate_embargo: R posts trigger_propose_embargo, C posts trigger_accept_embargo → em_state == EM.ACTIVE with R, C and V1 each a signatory to the active embargo 3. [coordinator] embargo_revision phase — call demo_propose_embargo_revision (DEMOMA-21-002): C posts trigger_propose_embargo_revision to the CASE_MANAGER (canonical ACTIVE → REVISE); V1 answers the relayed Invite with trigger_accept_embargo (consent); C, as case owner, posts trigger_accept_embargo (canonical REVISE → ACTIVE) → em_state returns to EM.ACTIVE with the revised terms on every replica after the ledger fan-out 4. [coordinator] v2_late_invite phase — invite V2 only after the revision is complete and em_state is confirmed ACTIVE (DEMOMA-21-003) → V2 accepts the case invite and the current embargo event, reaching becoming a signatory to it (DEMOMA-21-012) 5. [demo-script] fix_lifecycle phase — advance V1 and V2 through the fix lifecycle → the fix lifecycle completes for V1 and V2; the embargo stays ACTIVE until the accidental collapse in phase 6 6. [reporter] accidental_collapse phase — call actor_notifies_published on the reporter's client to fire CS.P; trigger_terminate_embargo MUST NOT be called (DEMOMA-21-004, DEMOMA-21-014) → CaseActor emits the embargo teardown via ThreatTerminationBranchNode and em_state == EM.EXITED on the Coordinator's replica 7. [demo-script] case_closure phase — close the case on every participant → RM.CLOSED for every participant in the CASE_MANAGER's store; each participant's replica ends at the entries recording its own closure (CM-23-004) 8. [demo-script] dump_case_ledgers phase → one case-ledger JSONL artifact per actor under devlogs/rcvv-embargo/, recording the proposal, the owner's acceptance, the revision proposal and its acceptance (DEMOMA-21-007) Postconditions - EM.EXITED as an automatic consequence of CS.P rather than a deliberate termination (variation d, DEMOMA-21-004); every participant at RM.CLOSED, which the universal Invariant 7 check ( test_invariant_7_log_terminates_all_rm_closed) asserts for every scenario rather than this group. Nothing beyond those is restated here — see DEMOMA-19-014's postcondition for why. |
|
DEMOMA-21-009 |
MUST | After phases 2, 3, 4, and 6 of the rcvv-embargo scenario, the scenario MUST assert the expected EM state before proceeding to the next phase. Phase-boundary EM assertions catch silent failures where an embargo trigger fires but the state machine does not advance. |
|
DEMOMA-21-010 |
MUST | After the revision sequence, demo_propose_embargo_revision MUST poll the CASE_MANAGER's canonical case and the accepting client's replica for em_state == EM.ACTIVE and assert both using demo_check.Confirming ACTIVE after the revision ensures the owner's decision completed and the embargo did not stall in REVISE state. The canonical case shows the decision was committed; the accepting client's replica shows the ledger fan-out reached a non-owner (#3918). |
|
DEMOMA-21-011 |
MUST | The demo_propose_embargo_revision function MUST be importable from vultron/demo/exchange/ and MUST NOT duplicate the propose-and-activate logic already in demo_propose_and_activate_embargo (DEMOMA-20-002).Reusing the activate path prevents divergence between the propose-and-activate and revision helpers when the embargo lifecycle nodes change. |
|
DEMOMA-21-012 |
MUST | The rcvv-embargo scenario MUST assert that V2 is a signatory to the active embargo before proceeding to the fix_lifecycle phase. V2 must be fully enrolled in the embargo before it is accidentally collapsed by CS.P, making the ledger record unambiguous about V2's consent at time of collapse. |
|
DEMOMA-21-013 |
MUST | The rcvv-embargo scenario MUST use polling helpers (not post_to_inbox_and_wait) to detect the invite in V2's inbox, consistent with the no-mail-carrying rule (DEMOMA-03-003).The no-mail-carrying rule requires that the demo orchestrator never post directly to another actor's inbox; polling ensures the invite arrives via the normal routing path. |
|
DEMOMA-21-014 |
MUST | The accidental_collapse phase of rcvv-embargo MUST NOT call trigger_terminate_embargo directly; the embargo collapse MUST arrive as an automatic consequence of CS.P, not a deliberate termination.Variation (d) specifically exercises the automatic embargo collapse path via ThreatTerminationBranchNode; calling trigger_terminate_embargo directly would test variation (e) instead. |
Causal Gating and Scenario Narratives
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-22-001 |
MUST | Every scenario script under vultron/demo/scenario/ MUST gate each asynchronous protocol boundary on a causal precondition satisfying EDF-06-001 through EDF-06-003 before driving the next trigger.This generalizes DEMOMA-11-009, which stated the rule for RVCV-handoff alone. Seven of the nineteen sub-issues of Epic #2136 were the same defect — a step proceeding before its enabling event had propagated — reproduced in a different scenario each time. A per-scenario requirement cannot prevent the next scenario from rediscovering it. See ADR-0058. |
Refines: EDF-06-001 |
DEMOMA-22-002 |
MUST | Causal gate predicates — the reusable functions that poll or scan an actor's own state to decide whether a precondition holds — MUST be defined in vultron/demo/helpers/. Scenario modules MUST NOT define their own polling or retry loops. This requirement does NOT relocate the demo_gate context manager itself, which DEMOCI-01-007 places in vultron/demo/utils.py alongside demo_step and demo_check.A MUST-level application of DEMOMA-17-001 (extract before reuse) to the gating vocabulary specifically. Hand-rolled loops in scenario modules are how the #2178 defect survived in rccv_handoff_demo.py after being fixed in rvcv_handoff_demo.py — the fix had nowhere shared to land. The split is deliberate: the control-flow primitive lives with the other context managers, and the observables it gates on live in the helper vocabulary. |
Refines: DEMOMA-17-001 Depends on: DEMOCI-01-007 — DEMOCI-01-007 owns the location and semantics of the demo_gate context manager; this requirement owns the location of the predicates passed to it. |
DEMOMA-22-003 |
MUST | Each demo scenario MUST have a narrative page under docs/topics/scenarios/ that describes the case's progress through the CVD process in domain terms, without reference to implementation details such as endpoint names, helper functions, or container topology. Each narrative step MUST state the antecedent that causes it.A narrative written in domain terms is an independent statement of what the protocol is supposed to accomplish. Because it is written without reference to the implementation, it can disagree with the implementation — which is precisely what makes it usable as a conformance oracle rather than a restatement of the code. |
|
DEMOMA-22-004 |
MUST | Each scenario narrative MUST carry a machine-readable list of causal edges. Each edge MUST name the antecedent event, the consequent event, and the actor that commits the consequent. The actor label MUST name the actor recorded on the consequent's ledger entry, the literal emitter of the activity. For an activity the CASE_MANAGER emits on a participant's behalf, such as every invite_actor_to_case, that is the CASE_MANAGER (CM-24-001, PCR-08-007), and the requesting participant is recoverable from the entry's attributedTo (CM-24-002), not from the label.Prose alone drifts silently from the code it describes. A structured edge list is the part of the narrative a test can check, and it forces the author to state the causal claim precisely enough to be falsifiable. |
|
DEMOMA-22-005 |
MUST | The invariant harness MUST assert, for each scenario, that every causal edge declared in that scenario's narrative appears in the observed case ledger, with the antecedent's log index preceding the consequent's. The harness MUST also check each edge's actor tag against the ledger: the consequent entry considered MUST be one whose recorded actor resolves, through the scenario's dump manifest, to the edge's consequent_actor, and an edge with no such entry, or whose recorded actor is absent from the manifest, MUST fail.The case ledger records log_index in causal order (ADR-0079, CLP-14-001) and the invariant harness already reads the per-scenario ledger dumps, so checking declared causality against observed causality needs no new instrumentation. This is what converts the narrative from documentation into a conformance check. |
Refines: DEMOCI-04-001 |
DEMOMA-22-006 |
MUST | A change to a scenario's protocol flow MUST update that scenario's narrative and its declared causal edges in the same pull request. Without this, the conformance check decays into a ratchet that is weakened whenever it fails. Updating both together makes a deliberate protocol change visible as a narrative change, which is the point. |
|
DEMOMA-22-007 |
MUST | Every wait_for_* call in a scenario script MUST be wrapped in either demo_gate (when the wait is a causal precondition for a subsequent step) or demo_check (when the wait is temporal or a non-gating post-hoc verification). Bare unwrapped wait_for_* calls are prohibited.A bare call raises AssertionError directly on timeout, bypassing the demo harness's failure accumulator. Earlier demo_check failures accumulated in the same run are lost, and downstream steps do not receive the structured skip that demo_gate provides — the exception terminates the scenario with no record of what preceded the failure. demo_gate and demo_check are the two sanctioned wrappers; using them consistently keeps the failure record complete and the control flow explicit. |
Refines: DEMOMA-22-001 — DEMOMA-22-001 requires causal gating at every async boundary; this requirement closes the loophole of bare calls that appear to gate but do not integrate with the failure accumulator. Refines: EDF-06-005 — EDF-06-005 requires unmet causal gates to prevent dependent steps; this requirement enforces the same principle for the bare-call case. |
Shared Scenario Harness
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-23-001 |
MUST | Every run_<name>_demo() orchestrator MUST run its phase body inside the shared scenario_harness("<demo-name>") context manager from vultron.demo.helpers.harness, which resets the failure accumulator, always dumps the case ledgers, and only then asserts demo success. The scenario's main() MUST NOT wrap the run in try: ... finally: assert_demo_success().Each scenario previously repeated the same three responsibilities in a slightly different order, and the finally: assert_demo_success() shape in main() substituted a generic DemoFailureError for whatever actually went wrong. One harness fixes the order once for all scenarios: run phases, always dump, then assert. |
Implements: DEMOCI-10-001 Refines: DEMOMA-17-001 |
DEMOMA-23-002 |
MUST | A scenario's _phase_dump_case_ledgers() MUST be a thin wrapper over vultron.demo.helpers.ledger_dump.dump_case_ledgers(), declaring only that scenario's dump targets (actor name, client, route key, and any fallback). Per-actor fetching, 404 handling, JSONL writing, and manifest writing MUST live in the shared helper, not in the scenario module.Nine copies of the same export loop is exactly the copy-paste that DEMOMA-17-001 forbids, and it is why a defect in the dump had to be fixed nine times. Keeping the scenario-specific participant map in the scenario and the mechanics in the helper puts each fact in one place. |
Refines: DEMOMA-17-001 |
DEMOMA-23-003 |
MUST | A scenario MUST register its dump with harness.dump_with(...) as soon as a case exists — immediately after the report-submission phase returns — rather than at the end of the run.The harness can only dump what it has been given. Registering the dump at the first point where a case exists is what makes every later phase failure survivable: the ledgers that existed at the moment of failure are still exported. |
Implements: DEMOCI-10-001 |
DEMOMA-23-004 |
SHOULD | When a scenario body raises, the harness SHOULD attach the accumulated demo_step/demo_check failures to the propagating exception as notes rather than raising DemoFailureError in their place.The accumulated soft failures are usually downstream consequences of the exception, so they are context, not the cause. Attaching them as notes keeps both: the reader sees the failure that stopped the run and every check that had already failed. |
Refines: DEMOCI-10-004 |
DEMOMA-23-005 |
MUST | Every wait for a set of replicas to reach contiguous ledger coverage of an authority's tail, such as the causal gate before the notes phase, MUST be performed through a shared helper in vultron/demo/helpers/sync.py that reads the authority tail, iterates the replicas, applies the named ledger-coverage timeout constants from vultron/demo/helpers/polling.py (a default budget and a wider late-joiner budget, neither below any budget a scenario carried before the loop was shared), and wraps each per-replica wait in a demo context internally. After case closure no replica covers the authority's tail, because a closed participant is sent nothing after the entries recording its own closure (CM-23-004). The closure-phase wait for a departed participant's replica to end at its own closure MUST also be performed through a helper in vultron/demo/helpers/sync.py.The same read-tail-then-poll-replicas block was repeated in every scenario module, once before the notes phase and once after closure (the post-closure copy is now the shared own-closure check, #4212). Its timeouts were literals that drifted independently — by module, by replica label, and in some modules by being omitted so the primitive's default applied — and the gate-versus-check choice had to be fixed module by module in one pull request (#2756). One helper puts the loop, the named constants, and the demo-context wrap in one place. |
Refines: DEMOMA-17-001 Refines: DEMOCI-01-011 — DEMOCI-01-011 requires a shared helper that encapsulates polling to perform the demo-context wrap itself; the coverage helper is such a helper. |
DEMOMA-23-006 |
MUST NOT | A module under vultron/demo/ other than vultron/demo/helpers/sync.py MUST NOT call or import wait_for_contiguous_ledger_coverage or _get_log_entries_for_case directly. Defining the primitive and re-exporting either name from a package __init__ are exempt.The race guard that the concern behind this requirement (#3042) feared was missing was in fact present in every scenario module; what was missing was the single place to fix it. Forbidding the direct call is what stops the coverage loop from being copied back into the next scenario written from a neighbour, which is how the copies arose. The ban covers helper modules too: when it named scenario modules only, a third copy of the loop ( drain_phase1_ledger in vultron/demo/helpers/polling.py) survived the consolidation, because a copy moved into a helper module was invisible to the check (#3906). |
Depends on: DEMOMA-23-005 Refines: DEMOMA-17-001 Refines: DEMOMA-22-002 — DEMOMA-22-002 places causal gate predicates in vultron/demo/helpers/; this requirement names the specific predicate that every scenario needs and forbids the scenario-local copy of it. |
DEMOMA-23-007 |
MUST | A scenario's replica sync-verification phase MUST be a thin call to a shared phase helper in vultron/demo/helpers/sync.py, declaring only the scenario's authority, replicas, late joiners, expected participant set, and the replica pairs to state-check.Keeping the scenario's facts in the scenario and the mechanics in the helper puts each fact in one place, exactly as DEMOMA-23-002 does for the ledger dump. A scenario-specific extra — the rv scenario's check that its dedicated case-actor container holds the case — follows the helper call as a separate step rather than becoming a per-scenario variant of the phase body. |
Depends on: DEMOMA-23-005 Refines: DEMOMA-17-001 Refines: DEMOMA-23-002 |
DEMOMA-23-008 |
MUST | A scenario whose ledger authority holds no ledger entries for the case MUST fail: an authority with an empty ledger is a demo failure, not a vacuous pass of the ledger checks. Checks that iterate over the authority's entries pass with nothing to check. An authority with no entries means the case never reached it, or its ledger was never read. #3905 made that state pass without an issue recording the change. |
Refines: DEMOMA-23-002 |
RCVD Scenario (Reporter + Coordinator + Vendor + Deployer)
Requirements for the RCVD scenario, which exercises the Deployer role as a participant distinct from the Vendor. The Vendor develops the fix (VFd) but does not deploy it; the Deployer applies the patch in their own environment (d→D), gated on the causal precondition that at least one Vendor in the case has reached VFd state (CSB-15-004). This is the first scenario in which a DEPLOYER-only participant (holding CVDRole.DEPLOYER but not CVDRole.VENDOR) completes the fix-deployment protocol step.
Trigger: Scenario Start — rcvd
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-24-001 |
MUST | The RCVD scenario MUST involve five actor identities — Reporter, Coordinator, Vendor, Deployer, and CaseActor — each with its own isolated DataLayer and separate container. The CaseActor MUST run as a separate actor identity on its own container; the Coordinator holds CVDRole.CASE_OWNER throughout the scenario. RCVD is the canonical scenario demonstrating the distinction between fix availability (Vendor's F event) and fix deployment (Deployer's D event). Five separate containers enforce true multi-container message delivery and per-actor DataLayer isolation, consistent with DEMOMA-01-001. The Coordinator as CASE_OWNER follows the RCV topology so that the Deployer role is the sole new element introduced. |
Refines: DEMOMA-01-001 Refines: DEMOMA-12-001 — Extends the RCV four-container topology by adding a fifth Deployer container |
DEMOMA-24-002 |
MUST | The Vendor actor MUST hold only CVDRole.VENDOR in the case; it MUST NOT hold CVDRole.DEPLOYER. The Vendor's participant VFD path MUST advance to VFd (fix ready) and MUST NOT advance to VFD (fix deployed). The RCVD scenario MUST verify, after the fix-ready milestone, that the Vendor's VFD state is VFd and that no VFD participant status snapshot exists for the Vendor actor. The central protocol distinction this scenario demonstrates is that fix availability (Vendor at VFd) and fix deployment (Deployer at VFD) are separate events attributed to separate actors. A Vendor-only actor cannot hold CVDRole.DEPLOYER; CSB-15-002 would block d→D for it regardless, but the scenario MUST make this explicit through role assignment and post- milestone verification. |
Refines: CSB-15-001 Refines: CSB-15-002 Refines: DEMOMA-15-001 |
DEMOMA-24-003 |
MUST | The Deployer actor MUST hold only CVDRole.DEPLOYER in the case; it MUST NOT hold CVDRole.VENDOR. The Deployer MUST be invited to the case by the Coordinator AFTER the fix-ready milestone is verified (Vendor at VFd), so that the causal precondition (CSB-15-004) is satisfied at the time of the Deployer's d→D transition. Inviting the Deployer after fix-ready ensures the causal gate (at least one VENDOR in the case at VFd) is satisfied when the Deployer attempts d→D. Inviting before fix-ready is a valid protocol scenario but requires a separate causal-gate check within the BT; the RCVD scenario intentionally uses the simpler post-fix-ready invite to isolate the pure-Deployer d→D path from the timing complexity of pre-fix-ready invites. |
Refines: CSB-15-002 Refines: CSB-15-004 Refines: DEMOMA-15-002 |
DEMOMA-24-004 |
MUST | The RCVD scenario MUST exercise the causal-gate d→D path for the DEPLOYER-only participant as specified in CSB-15-004: the BT MUST permit the Deployer's d→D transition because at least one VENDOR participant in the case is at VFd, and MUST block d→D for any participant that holds neither CVDRole.DEPLOYER nor satisfies the causal precondition. CSB-15-004 defines a new guard condition for DEPLOYER-only actors that replaces the per-participant VFD-state check used by VENDOR actors. The RCVD scenario is the primary functional test exercising this path end-to-end in a multi-container context. The scenario-level verify_fix_deployed call MUST pass the Deployer actor ID (not the Vendor actor ID) to confirm that DEMOMA-15-002 is satisfied.ECA DetailsPreconditions - All five containers running (Reporter, Coordinator, Vendor, Deployer, CaseActor); all actors seeded and cross-registered; the Coordinator holds CVDRole.CASE_OWNER throughout (DEMOMA-24-001). Steps 1. [reporter] POST submit-report trigger to the Coordinator, which validates and engages the case → the case exists on the Coordinator's replica; RM→ACCEPTED 2. [coordinator] invite the Vendor (CVDRole.VENDOR only) into the case → the Vendor accepts and holds no CVDRole.DEPLOYER (DEMOMA-24-002) 3. [vendor] advance the participant VFD path to VFd without deploying → Vendor VFD = VFd and no VFD participant status snapshot exists for the Vendor actor (DEMOMA-24-002); the CSB-15-004 causal precondition is now satisfied 4. [coordinator] invite the Deployer (CVDRole.DEPLOYER only) into the case, AFTER the fix-ready milestone is verified (DEMOMA-24-003) → the Deployer container is polled for the arriving invitation before the accept-case-invite trigger is driven (DEMOMA-24-008); the Deployer holds no CVDRole.VENDOR 5. [deployer] apply the patch in its own environment, driving the d→D transition → the BT permits d→D because a VENDOR participant is at VFd, and blocks d→D for any participant that holds neither CVDRole.DEPLOYER nor satisfies the causal precondition 6. [demo-script] drive publication, calling verify_publicly_disclosed with the Deployer actor ID as the DEPLOYER-role participant (DEMOMA-24-005) → PXA advances to Pxa on every replica; EM.EXITED 7. [demo-script] close the case on every participant, then dump the case ledgers → RM.CLOSED for every participant in the CASE_MANAGER's store; each participant's replica ends at the entries recording its own closure (CM-23-004); one case-ledger JSONL artifact per actor under devlogs/rcvd/ Postconditions - The final state DEMOMA-24-005 requires. Deliberately a pointer rather than a copy: -005 is the authority for that state, and a restatement here would drift from it with nothing able to tell which was current (MS-16-002). |
Implements: CSB-15-004 Refines: DEMOMA-15-002 |
DEMOMA-24-005 |
MUST | The RCVD scenario MUST reach a final state where: Vendor VFD = VFd; Deployer VFD = VFD; all participants (Reporter, Coordinator, Vendor, Deployer, CaseActor) PXA = Pxa; EM.EXITED; and RM.CLOSED for all participants. The verify_publicly_disclosed helper call MUST use the Deployer actor ID as the participant with DEPLOYER role.Verifying Vendor stops at VFd and Deployer reaches VFD confirms the role- separated VFD model. Using the Deployer actor ID in verify_publicly_disclosed exercises the existing vfd_roles branch that treats DEPLOYER the same as VENDOR for public-state verification. |
Refines: DEMOMA-03-002 Refines: DEMOMA-12-005 |
DEMOMA-24-006 |
MUST | The implementation of the RCVD scenario MUST register it with the scenario registry (DEMOCI-11-001) in the same PR as the scenario script, so that its generated .github/demo-scenarios.json entry (DEMOCI-11-004) runs it as its own CI matrix job and CI failures are independently reported per DEMOCI-03-003. |
Refines: DEMOCI-03-002 Refines: DEMOCI-03-003 Refines: DEMOMA-12-007 |
DEMOMA-24-007 |
MUST | The RCVD implementation MUST ship a dedicated per-scenario case-ledger invariant test file (test/ci/invariants/test_rcvd_invariants.py) using the shared invariant library; the file MUST assert the scenario-specific checks: (1) invite_actor_to_case present at least three times (Reporter, Vendor, Deployer invitations), (2) accept_invite_actor_to_case present at least three times, (3) add_participant_status_to_participant entries show VFd (not VFD) for Vendor and VFD for Deployer, and (4) the Deployer replica holds the full case history from genesis.The VFd-vs-VFD distinction for Vendor and Deployer is the central protocol claim of the RCVD scenario; without an invariant test that explicitly checks both states, a regression that lets the Vendor silently reach VFD would pass CI undetected. |
Refines: DEMOMA-12-008 Refines: DEMOMA-12-009 |
DEMOMA-24-008 |
MUST | The RCVD scenario MUST run deterministically end-to-end. The Deployer container MUST be polled for the arriving case invitation before the accept-case-invite trigger is driven, following the polling pattern established by DEMOMA-12-013. |
Refines: DEMOMA-09-004 Refines: DEMOMA-12-013 |
VC Scenario (Vendor self-reports; Coordinator joins as Observer)
Trigger: Scenario Start — vc
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-25-001 |
MUST | The VC scenario MUST involve three actor identities — Vendor, Coordinator, and CaseActor — each with its own isolated DataLayer and separate container. The CaseActor MUST run as a separate actor identity on the dedicated case-actor container (not co-located on the Vendor container), and docker-compose-multi-actor.yml MUST configure Vendor with VULTRON_ACTOR__CASE_ACTOR_SERVICE_URL=http://case-actor:7999/api/v2.Using the shared case-actor container for Vendor-owned cases matches the topology used by other scenarios where a non-Coordinator actor holds CASE_OWNER, keeps the CaseActor identity consistent across scenarios, and exercises true multi-container message delivery. All three containers (vendor, coordinator, case-actor) already exist in docker-compose-multi-actor.yml; no new infrastructure is required. |
Refines: DEMOMA-01-001 |
DEMOMA-25-002 |
MUST | In the VC scenario, Vendor MUST submit the initial vulnerability report to its own inbox using reporter_submits_report with Vendor in BOTH the reporter and receiver slots. No external Reporter actor exists; there are no interactions outside the protocol. Vendor then validates the report (RM→RECEIVED→VALID) and engages the case (RM→ACCEPTED→case created), becoming CASE_OWNER with CVDRole.VENDOR + CVDRole.CASE_OWNER.Vendor-as-reporter means the discoverer and the responsible fixer are the same actor. Using reporter_submits_report unchanged ensures the standard RM intake path is exercised rather than a shortcut; the only difference from a normal report submission is that the reporter and receiver share an identity. This matches "THERE ARE NO INTERACTIONS OUTSIDE THE PROTOCOL": no ad-hoc case injection or create-case trigger bypass.ECA DetailsSteps 1. [vendor] POST submit-report trigger directed at self (vendor inbox) → Report delivered to Vendor inbox via standard HTTP delivery path 2. [vendor] BT processes inbound report; RM transitions RECEIVED→VALID → Report validated; RM state VALID 3. [vendor] BT engages case; RM transitions VALID→ACCEPTED → VulnerabilityCase created on Vendor/CaseActor; Vendor holds CVDRole.VENDOR + CVDRole.CASE_OWNER; default embargo initialized EM.ACTIVE (EP-04-001) |
Refines: CM-02-001 Refines: EP-04-001 |
DEMOMA-25-003 |
MUST | Vendor MUST invite Coordinator into the case using the invite-actor-to-case trigger. Coordinator's case role MUST be CVDRole.OBSERVER only — NOT CVDRole.COORDINATOR. Coordinator MUST accept the invitation via the accept-case-invite trigger and MUST become an embargo signatory, which, because the case has an active embargo, is what makes it an active participant entitled to case-content announcements as an Observer (CM-10-004, CM-25, CM-26).An OBSERVER that has not joined, or that is not a signatory while an embargo is active, is inert and receives no case updates (CM-10-004). The Invite/Accept exchange (CM-25) is the correct admission mechanism regardless of role. Coordinator's role is explicitly limited to OBSERVER: assigning CVDRole.COORDINATOR here would be semantically incorrect because the Vendor, not the Coordinator, owns the case and drives coordination. ECA DetailsSteps 1. [vendor] POST invite-actor-to-case trigger for Coordinator with OBSERVER role → Invite(Actor, VulnerabilityCaseStub) sent to Coordinator inbox 2. [demo_script] Poll Coordinator DataLayer until Invite(Actor, VulnerabilityCaseStub) is visible → Invitation record present in Coordinator replica 3. [coordinator] POST accept-case-invite trigger directed to CaseActor → Coordinator becomes a signatory with CVDRole.OBSERVER; CaseActor emits Announce(VulnerabilityCase) to seed Coordinator replica |
Refines: CM-25-001 Refines: CM-26-001 |
DEMOMA-25-004 |
MUST | In the VC scenario, only Vendor MUST follow the VFD fix-deployment path (vfd → Vfd → VFd → VFD). Coordinator, holding only CVDRole.OBSERVER, MUST NOT be driven through VFD steps. Coordinator tracks pxa state updates only (public-awareness transitions shared with all signatory participants).VFD obligations attach to CVDRole.VENDOR and CVDRole.DEPLOYER per CM-26. OBSERVER carries no VFD obligations unless the actor also holds VENDOR or DEPLOYER. Driving Coordinator through VFD triggers in this scenario would incorrectly exercise a code path that the OBSERVER role is not supposed to activate. |
Refines: CM-26-001 |
DEMOMA-25-005 |
MUST | The VC scenario MUST reach a final state in which both Vendor and Coordinator have transitioned to RM.CLOSED. Vendor MUST reach VFDPxa CS with EM.EXITED. Coordinator MUST reach pxa CS with EM.EXITED (Observer-only state; no VFD suffix). The scenario verifies LedgerFanout replica convergence for the Coordinator replica against the authoritative Vendor/CaseActor state after each major protocol event. | Refines: DEMOMA-03-002 Refines: EP-04-001 |
DEMOMA-25-006 |
MUST | The implementation of the VC scenario MUST register it with the scenario registry (DEMOCI-11-001) in the same PR, so that its generated .github/demo-scenarios.json entry (DEMOCI-11-004) runs it as its own CI matrix job, and MUST ship a dedicated per-scenario case-ledger invariant test file (test/ci/invariants/test_vc_invariants.py) built on the shared invariant library. The test MUST assert the semantic-type list from DEMOMA-16-015 and the pxa-only final state for Coordinator. |
Refines: DEMOCI-03-002 Refines: DEMOCI-03-003 Refines: DEMOMA-16-015 |
ActorSession — typed demo trigger API
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-26-001 |
MUST NOT | Demo scripts under vultron/demo/scenario/ and vultron/demo/exchange/ MUST NOT call post_to_trigger directly. All trigger endpoint calls MUST be made through ActorSession typed methods.post_to_trigger accepts a bare string behavior name and an untyped body dict. A misspelled behavior name is a 404 at runtime; a misspelled body key is silently dropped by extra="ignore" request models (TRIG-03-002), producing an undetectable no-op. ActorSession makes both mistakes unrepresentable at the call site. post_to_trigger remains as the private implementation of ActorSession._post and MUST NOT be imported outside vultron/demo/utils.py and vultron/demo/actor_session.py. |
Refines: DEMOMA-17-001 |
DEMOMA-26-002 |
MUST | ActorSession MUST be a frozen dataclass with fields client: DataLayerClient, actor: as_Actor, case_id: str | None = None, and narrate: bool = True. The __post_init__ method MUST perform the container/actor authority check (formerly _assert_client_hosts_actor), running it exactly once at construction rather than on every trigger call.Moving the authority check to construction makes a mismatched (client, actor) pair unconstructible rather than a runtime guard that silently passes in unit tests (where neither side is a real absolute URI). The frozen constraint prevents callers from drifting the pairing after construction. |
Refines: DEMOMA-26-001 |
DEMOMA-26-003 |
MUST | ActorSession.with_case(case: as_VulnerabilityCase | str) -> ActorSession MUST return a new frozen ActorSession with case_id set to the case's ID (or the given ID). It MUST NOT modify the receiver. Case-scoped trigger methods MUST raise if self.case_id is None at call time.Cases do not exist at session construction time; the actor must exist before the case does. The immutable-copy pattern ( with_case) matches the frozen dataclass semantics and avoids a mutable binding that could drift between steps. A trigger names its case by ID, so the session keeps only the ID: a caller holding just the ID binds it directly rather than building a placeholder case, which could not name the case's owner (#5092). |
Refines: DEMOMA-26-002 |
DEMOMA-26-004 |
MUST | ActorSession MUST expose one typed method per trigger verb called from vultron/demo/. Each method MUST accept only the keyword arguments that are meaningful for that verb (no **kwargs). An architecture ratchet test MUST verify that each method's keyword parameter set is a subset of the corresponding trigger request model's fields minus actor_id.A **kwargs shim or behavior: str pass-through discards the static checking that is the entire point of ActorSession. The drift test closes the extra="ignore" silent-drop class at the call site: if the method accepts a kwarg the request model does not have, a misspelled key would pass through silently. Discovered via #3356. |
Refines: DEMOMA-26-001 |
DEMOMA-26-005 |
MUST | vultron/demo/helpers/actions.py MUST be deleted when ActorSession lands. No backward-compatibility shims or re-export stubs for its functions are permitted. All callers MUST be migrated to ActorSession methods in the same PR.actions.py is a 1:1 wrapper layer over post_to_trigger that ActorSession wholly subsumes. Keeping it alongside ActorSession would leave two competing APIs for the same operations, undoing the consolidation goal. |
Refines: DEMOMA-26-001 |
DEMOMA-26-006 |
MUST | The architecture ratchet test test/architecture/test_demo_trigger_client_matches_actor.py MUST be deleted in the same PR that removes the last post_to_trigger call site from vultron/demo/. It MUST NOT be deleted before that point; until then it serves as the migration's own safety net against regressions.The ratchet hunts the (client, actor_id) mismatch that ActorSession makes structurally unrepresentable. Once no post_to_trigger call sites remain, the class of bug it hunts cannot be written, so the ratchet is redundant. Keeping it past that point is maintenance burden with no safety value. |
Refines: DEMOMA-26-001 |
RCV-Reject Scenario (Coordinator invites Vendor; Vendor rejects the invitation)
Trigger: Scenario Start — rcv-reject
| ID | Priority | Requirement | Related |
|---|---|---|---|
DEMOMA-27-001 |
MUST | The RCV-reject scenario MUST involve four actor identities — Reporter, Coordinator, Vendor, and CaseActor — each with its own isolated DataLayer and separate container, with the Coordinator holding CVDRole.CASE_OWNER and the CaseActor holding CVDRole.CASE_MANAGER throughout. The Vendor MUST be seeded as a full actor identity even though it never becomes an active participant (CM-10-004). The topology is DEMOMA-12's (RCV): the scenario exists to isolate one divergence — the invitation response — so every other variable is held equal. The Vendor needs a real identity and container because the rejection is a protocol message it must author; a placeholder actor could not send Reject(Invite(Actor, VulnerabilityCaseStub)), and the point of the scenario is that the rejection travels the wire. |
Refines: DEMOMA-01-001 Refines: DEMOMA-12-001 |
DEMOMA-27-002 |
MUST | The Vendor MUST reject the case invitation by sending Reject(Invite(Actor, VulnerabilityCaseStub)), and the CaseActor MUST record a reject_invite_actor_to_case ledger entry. The Vendor's participant record, created by the stub Invite, MUST end at RM.CLOSED; the Vendor MUST NOT become active, MUST NOT receive a case ledger replica, and MUST NOT appear as a recipient of any subsequent case-content fan-out.This is the only scenario covering the invitation-layer rejection path, so it is the only place the protocol's "invited but never participating" outcome is exercised end to end. The negative half is the load-bearing half: a rejection that still made the Vendor active, or still shipped a replica, would satisfy a check that only looked for the ledger entry while leaving the case in a state the protocol forbids (ADR-0114). |
Refines: DEMOMA-27-001 |
DEMOMA-27-003 |
MUST | The scenario MUST execute its phases in order — report submission, invitation and rejection, replica-sync verification, notes exchange, publication, case closure, ledger dump — and the Reporter and Coordinator MUST complete publication and closure without the Vendor. Phase order is the causal order (EDF-06, ADR-0058): the rejection must precede sync verification, because what sync verification asserts is the absence of a Vendor replica, and asserting that before the rejection has been processed would pass vacuously. Closing the case without the Vendor is the scenario's demonstration that a rejected invitation does not block lifecycle completion for the remaining participants. ECA DetailsPreconditions - All four containers running (Reporter, Coordinator, Vendor, CaseActor); all actors seeded and cross-registered; no VulnerabilityCase exists for this report; the Coordinator is pre-configured with the dedicated CaseActor container URL (DEMOMA-27-001). Steps 1. [reporter] report_submission phase — POST submit-report trigger directed at the Coordinator, which validates the report and engages the case → VulnerabilityCase created with the Coordinator as CVDRole.CASE_OWNER and the CaseActor as CVDRole.CASE_MANAGER on its own container (DEMOMA-27-001) 2. [coordinator] invite_vendor_reject phase — Coordinator sends Invite(Actor, VulnerabilityCaseStub) to the Vendor with CVDRole.VENDOR → invite_actor_to_case recorded in the case-actor log (DEMOMA-16-011)3. [vendor] Vendor responds with Reject(Invite(Actor, VulnerabilityCaseStub)) rather than Accept(...) → reject_invite_actor_to_case recorded; the Vendor's participant record is at RM.CLOSED, it never becomes active, and it holds no case ledger replica (DEMOMA-27-002)4. [coordinator] sync_verification phase — verify replica agreement across the participants that exist → Reporter and Coordinator replicas agree with the case-actor log; no Vendor replica is present, which is the assertion the rejection makes checkable (DEMOMA-27-002) 5. [coordinator] notes_exchange phase — participants exchange case notes → add_note_to_case recorded (DEMOMA-16-001)6. [coordinator] publication phase — the case is published → participants advance to the public-aware CS.pxa states 7. [coordinator] case_closure phase — the Coordinator and Reporter each close the case → every participant at RM.CLOSED, which the universal Invariant 7 check ( test_invariant_7_log_terminates_all_rm_closed) asserts for every scenario rather than this group8. [coordinator] dump_case_ledgers phase → one case-ledger JSONL artifact per participating actor under devlogs/rcv-reject/ Postconditions - The case is closed with the Reporter and Coordinator as its only participants, and the case-actor log holds exactly one reject_invite_actor_to_case entry and no accept_invite_actor_to_case entry (DEMOMA-16-011, DEMOMA-27-002). |
Refines: DEMOMA-27-001 |
Notes Frontmatter
Requirements for YAML frontmatter in notes files, covering the required schema, a Pydantic-based loader, validation enforcement, migration requirements, what a note is for and how it is written, and the report that flags delivered plan notes.
File ID: NF
Pydantic Schema Module
Loader
| ID | Priority | Requirement | Related |
|---|---|---|---|
NF-03-001 |
MUST | The vultron/metadata/notes/ subpackage MUST provide a loader that discovers and parses all notes/*.md files relative to the repository root. |
|
NF-03-002 |
MUST | The loader MUST skip notes/README.md. |
|
NF-03-003 |
MUST | The loader MUST parse each file's YAML frontmatter block and validate it against NotesFrontmatter, raising a ValueError (or equivalent) on schema violations. |
|
NF-03-004 |
MUST | The loader MUST return a registry mapping file path to validated NotesFrontmatter instance. |
|
NF-03-005 |
SHOULD | The loader SHOULD resolve the repository root by searching upward from the current working directory for a pyproject.toml file, so it works correctly regardless of where it is called from. |
|
NF-03-006 |
MUST | The ValueError required by NF-03-003 MUST name the offending notes file per MS-17-001, and the loader MUST obtain that attribution from the shared helper named by MS-17-003. This applies to the frontmatter parse as well as the NotesFrontmatter validation — a malformed frontmatter block MUST NOT surface as a raw parser traceback.The loader walks every file in notes/, so a schema violation reported as a bare field path names no file. The parse step was the wider gap: a frontmatter block that fails to scan raised a YAML error, which is not a ValueError, so it escaped NF-03-003's documented contract entirely and reached the pre-commit hook as a traceback. |
Implements: MS-17-001 Implements: MS-17-003 Refines: NF-03-003 — constrains the error NF-03-003 requires to name its file |
Validation Enforcement
Future: Registry and Query Tool
Plan Retirement Report
| ID | Priority | Requirement | Related |
|---|---|---|---|
NF-09-001 |
MUST | The notes-tracks command MUST list every plan note whose tracked issues (NF-01-010) are all closed, and every plan note that tracks a number that is not a known issue. |
|
NF-09-002 |
MUST NOT | The notes-tracks command MUST NOT archive, edit, or fail on a flagged note; a flagged note is an agenda item for the next notes sweep, decided by a person or the sweep's interview. The command exits non-zero only when the issue lookup itself fails.A closed issue is evidence, not proof, that a plan is delivered; follow-up bugs and re-scoped work often keep a plan relevant past its first issue. |
Refines: NF-09-001 |
Object ID
Requirements for the format and handling of object identifiers throughout the Vultron system. Establishes full-URI IDs as the canonical form for all ActivityStreams objects and DataLayer records.
File ID: OID
ID Format
| ID | Priority | Requirement | Related |
|---|---|---|---|
OID-01-003 |
MUST | The canonical base URI for locally created objects MUST be configurable via an environment variable (VULTRON_BASE_URL or equivalent)How a deployment sets the base of the IDs it mints is an operating choice of this codebase; a peer sees only the resulting IDs. Only this codebase must obey it, so it is project (ADR-0134). |
|
OID-01-004 |
MUST | Helper functions for constructing full-URI IDs from UUIDs MUST be provided in a shared utility module (e.g., vultron.as_vocab.utils.make_id) |
|
OID-01-005 |
MUST | Actor IDs MUST be normalized to full URI form at the point of first establishment — actor creation, seed data loading, or session initialization When this codebase normalizes an actor ID is an internal choice. What a peer observes, an id that is a full IRI, is ActivityStreams' rule (MV-05-001). Only this codebase must obey the normalization point, so it is project (ADR-0134). |
Depends on: OID-01-003 |
OID-01-006 |
MUST | A receiver MUST store and ledger a received activity as received, and MUST normalise every actor id it extracts from that activity to act on — to look up, compare with a stored id, or store as a key — at the point of extraction, by stripping a trailing slash File the letter as received; normalise what you copy out of it. The stored activity and the ledger postmark are evidence of what the sender wrote (ADR-0107), so they keep the sender's spelling. The values the receiver works with must have one spelling, or exact lookups miss records written in the other: a peer that spells its id with a trailing slash stopped matching its own CASE_OWNER records once the proposal tree stored the canonical spelling (#4775). Normalising where the value is extracted, not where it is compared, lets core trust one spelling (ADR-0032). |
Refines: OID-01-005 — Extends normalisation at first establishment to ids extracted from received activities. |
OID-01-007 |
MUST | The wire extractor MUST normalise a received activity's sender id before it builds the received event, and a received event MUST refuse at construction a sender id that ends in a trailing slash The sender id reaches every received use case, so normalising it once at the edge replaces a trailing-slash-tolerant comparison at each of its uses. Refusing an unnormalised id at construction makes a path that skips the extractor (a trigger, a test fixture) fail where it builds the event instead of storing the other spelling (ARCH-10-001). An id the sender wrote elsewhere in the activity keeps its spelling until it is extracted (OID-01-006), so comparing it with the sender id still needs same_actor_id (CLP-10-021). The normalising function must be importable from vultron/wire/ and vultron/core/models/, which may not import vultron/core/predicates/ (ARCH-22-001). |
Refines: OID-01-006 Depends on: ARCH-10-001 Depends on: ARCH-22-001 |
DataLayer Handling
Blackboard Key Handling
| ID | Priority | Requirement | Related |
|---|---|---|---|
OID-03-001 |
MUST NOT | Behavior Tree blackboard keys derived from object IDs MUST NOT use the raw full URI as the key | Refines: BT-03-003 |
Migration Requirements
| ID | Priority | Requirement | Related |
|---|---|---|---|
OID-04-002 |
MUST | Any migration of existing bare-UUID records MUST be accompanied by a migration script or documented manual steps |
Outbox
Requirements for actor outboxes, which hold response activities generated by handlers. Covers outbox structure, population, delivery, local and federated dispatch, idempotency, outbound reference dehydration, and outbox addressing rules.
File ID: OX
Outbox Structure
| ID | Priority | Requirement | Related |
|---|---|---|---|
OX-01-001 |
MUST | Each actor MUST have an outbox collection | |
OX-01-002 |
MUST | Outbox MUST preserve insertion order (FIFO) | |
OX-01-003 |
MUST | Activities added to outbox MUST be persisted to DataLayer immediately | |
OX-01-004 |
MUST | At most one drain of an actor's outbox MUST run at a time. A drain requested while another is draining the same outbox MUST wait for it (or be satisfied by it) rather than pop rows from the same queue concurrently. Every inbound activity's background task and the OutboxMonitor call the drain, and concurrent drains each pop the next row and await its POST, so rows complete delivery in an order unrelated to their enqueue order (28 in flight at once, a 14-row backfill arriving as 4, 7, 1, 8, 5, 2, … in CI run 35917721682, #3602). Serializing the drain is what makes OX-01-002's FIFO mean anything at the recipient. See ADR-0112. |
Refines: OX-01-002 |
OX-01-005 |
MUST | Delivery MUST preserve enqueue order per recipient: for any two queued activities that share a recipient, the earlier-enqueued one MUST have completed its delivery attempt to that recipient before the later one is attempted. A ledger fan-out is one Announce(CaseLedgerEntry) row per participant, and a replica that receives entry N+1 before entry N buffers a forward gap and sends a Reject(CaseLedgerEntry) (SYNC-14-002), which the CASE_MANAGER answers with a full-suffix replay into the same outbox. Per-recipient order at delivery is the property the ledger transport actually needs; order across recipients is not. See ADR-0112 (docs/adr/0112-per-recipient-ordered-outbox-delivery.md). |
Refines: OX-01-002 |
OX-01-006 |
MUST | An actor's outbox MUST NOT have more than one delivery in flight to the same recipient at a time. Deliveries to distinct recipients MAY proceed concurrently. One in-flight delivery per (actor, recipient) is how OX-01-005 is enforced without falling back to a single serial drain, in which one slow or unreachable recipient (a 30 s POST timeout, SYNC-05-004) would hold up every other recipient's rows. See ADR-0112 (docs/adr/0112-per-recipient-ordered-outbox-delivery.md). |
Refines: OX-01-005 |
Outbox Population
Outbox Delivery
| ID | Priority | Requirement | Related |
|---|---|---|---|
OX-03-002 |
MUST | Delivery MUST occur after the generating handler completes | |
OX-03-003 |
MUST NOT | Delivery MUST NOT block inbox HTTP response |
Local Delivery
Federation Delivery
Delivery Idempotency
| ID | Priority | Requirement | Related |
|---|---|---|---|
OX-06-001 |
MUST | Outbox delivery MUST be idempotent | Depends on: ID-02-001 |
OX-06-002 |
MUST | An actor's outbox MUST hold at most one pending entry per activity id: queuing an activity whose id is already pending in that outbox MUST NOT add a second entry, and the queuing operation MUST report whether it added one. An id that is no longer pending MUST be queued again when asked, under the same id. An emitting tree whose retry rebuilds its activity under the id the earlier attempt used therefore sends that activity once. A retry after a partial failure must not send an act twice. When the retry rebuilds the activity under its first id (ID-04-004), the outbox alone can tell it is already on its way, whatever the order of commit and send (ADR-0119). The check is in the queue operation so no caller can omit it. A delivered id is queued again and the receiver drops it by id (ID-02-001), as for a re-sent response (CP-05-006). |
Refines: OX-06-001 Depends on: ID-02-001 |
Outbound Body Sealing and Relay
| ID | Priority | Requirement | Related |
|---|---|---|---|
OX-07-001 |
MUST | Every adapter that persists an outbound activity for its actor's outbox MUST seal the activity's body — the factory object's own JSON dump — in that actor's store at the moment it persists the activity, and the outbox handler MUST deliver that sealed body exactly as sealed. The handler MUST NOT read the activity record back to build the payload, MUST NOT dehydrate, expand, hydrate, re-type, or re-serialise any field of the body, and MUST drop (with an ERROR log) a queued id for which no sealed body exists rather than deliver a reconstruction. The activity record cannot serve as the delivery payload: persistence dehydrates reference fields to ids and read-back rehydrates them from whatever the store holds now, so a re-read is a reconstruction and the ledger's payloadSnapshot (the factory blob, VM-08-003) and the wire disagreed — an Invite's enriched case stub reached the invitee as a bare URI. Reference fields (actor, target, to, ...) are URI strings or intentional stubs because the factory built them so; shaping them is the factory's responsibility, never the handler's. |
Depends on: VM-08-003 |
OX-07-002 |
MUST NOT | The object field of an outbound activity MUST NOT be dehydrated to a reference at any stage — construction, persistence of the sealed body, or delivery. A sealed body whose object is a bare URI or as_Link on an initiating activity is a factory defect that the handler refuses (AKM-03-002), never repairs. |
Depends on: AKM-03-001 |
Outbox Addressing
Outbox Monitor Efficiency
Signed Remote Delivery
| ID | Priority | Requirement | Related |
|---|---|---|---|
OX-10-001 |
SHOULD | For remote actor delivery, the outgoing HTTP POST SHOULD be signed using HTTP Message Signatures (RFC 9421) with the sending actor's private key | |
OX-10-002 |
MUST | The signing key pair MUST be per-actor and MUST NOT be shared across actors | Depends on: OX-10-001 |
OX-10-003 |
MUST | The signed delivery adapter MUST be transport-only: it MUST NOT construct or inspect ActivityStreams objects; serialization happens before the payload reaches the adapter Preserves the hexagonal layer boundary |
Depends on: OX-10-001 |
OX-10-004 |
MUST | Until signed delivery is implemented, the production HTTP delivery adapter MUST raise NotImplementedError if instantiated, so that callers receive an explicit signal rather than a silent no-op. |
Shared-Inbox Fan-Out
| ID | Priority | Requirement | Related |
|---|---|---|---|
OX-11-001 |
MAY | The system MAY expose a shared inbox endpoint that accepts activities addressed to multiple local actors in a single HTTP request | |
OX-11-002 |
MUST | If a shared inbox is implemented, it MUST validate the HTTP Signature on the inbound request before any fan-out processing | Depends on: OX-11-001 |
OX-11-003 |
MUST | If a shared inbox is implemented, it MUST enqueue one per-actor inbox delivery for each addressed local actor identified in the activity to/cc fields | Depends on: OX-11-001 |
OX-11-004 |
MUST | Until shared-inbox is implemented, adapters/driving/shared_inbox.py MUST raise NotImplementedError if instantiated, so that callers receive an explicit signal rather than a silent no-op |
Uniform HTTP Delivery
| ID | Priority | Requirement | Related |
|---|---|---|---|
OX-12-001 |
MUST | All inter-actor activity delivery MUST occur over the REST inbox/outbox HTTP API (HTTP POST to {actor}/inbox/). | Supersedes: ARCH-17-001 |
OX-12-005 |
MUST | Delivery to a co-located actor MUST use the same HTTP path as delivery to a remote actor. Uniform delivery exposes inter-actor delivery bugs that in-process shortcuts would mask, giving accurate confidence in demos and production. See ADR-0042. |
Refines: OX-12-001 |
OX-12-006 |
MUST NOT | There MUST NOT be an in-process delivery shortcut that bypasses HTTP for same-server recipients. An in-process shortcut makes co-located delivery behave differently from remote delivery, masking bugs and giving false confidence. See ADR-0042. |
Refines: OX-12-001 |
OX-12-002 |
MUST | The default production ActivityEmitter MUST be the HTTP delivery adapter. | Supersedes: ARCH-17-002 |
OX-12-007 |
MUST NOT | The ASGIEmitter in-process delivery adapter MUST NOT be wired as the default emitter. Wiring ASGIEmitter as default restores the in-process shortcut prohibited by OX-12-001 and masks inter-actor delivery bugs. See ADR-0042. |
Refines: OX-12-002 |
OX-12-008 |
MUST | The ASGIEmitter in-process delivery adapter MUST be removed from the codebase. Retaining dead code invites accidental re-use and contradicts the ADR-0042 decision to retire the in-process delivery path entirely. |
Refines: OX-12-002 |
OX-12-003 |
MUST | Application code MUST NOT construct httpx.ASGITransport directly. The sole exception is FastAPI's TestClient, which uses ASGITransport internally to drive a single application's own endpoints and is not an inter-actor delivery mechanism. |
Delivery Terminal State
| ID | Priority | Requirement | Related |
|---|---|---|---|
OX-13-001 |
MUST | Each outbox queue entry MUST track a total attempt count that is persisted across drain passes and incremented on every delivery failure, so that the count survives outbox_handler invocations and reflects the activity's cumulative delivery history. | |
OX-13-002 |
MUST | The maximum total delivery attempts MUST be configurable. | |
OX-13-007 |
MUST | The maximum total delivery attempts MUST have a documented default value. A documented default makes the system's reliability envelope observable and predictable without requiring explicit per-deployment configuration. |
Refines: OX-13-002 |
OX-13-008 |
MUST | When an activity exhausts its total attempt budget, it MUST be moved to a dead-letter store. Moving exhausted activities to a dead-letter store preserves them for operator inspection and provides an observable signal for the terminal failure state. |
Refines: OX-13-002 |
OX-13-009 |
MUST NOT | When an activity exhausts its total attempt budget, it MUST NOT be requeued. Requeuing an exhausted activity restarts the attempt counter, defeating the total-attempt bound and allowing indefinite retries. |
Refines: OX-13-002 |
OX-13-003 |
MUST | On activity exhaustion, the system MUST log at ERROR level, including the activity ID, the recipient(s) that could not be reached, and the total number of attempts made, so that the operator has a visible signal of the terminal failure. | Refines: OX-13-002 |
OX-13-004 |
MUST | Dead-letter entries MUST be readable from the DataLayer so that operators can inspect exhausted activities without access to application logs. | |
OX-13-005 |
MUST | HTTP 4xx responses from a recipient inbox MUST be treated as terminal delivery failures: the activity MUST NOT consume inner retry slots for a 4xx response. Only HTTP 5xx responses, connection errors, and timeouts are retryable. A deterministic 4xx (e.g. 422 Unprocessable Entity for a schema-invalid payload) will never succeed with the same payload; retrying wastes inner retry budget and contributes to connection storms. See CONCERN-2302 §3. |
|
OX-13-006 |
MUST NOT | A single failing activity in an actor's outbox MUST NOT abort delivery of other activities in the same drain pass. The previous err_count was a single function-local variable reset per-pass, so a single bad activity could exhaust the cap before healthy activities were reached, delaying ledger-entry delivery past scenario timeouts. See CONCERN-2302 §6/§7. |
|
OX-13-010 |
MUST | Each activity's per-pass error count MUST be tracked independently. Independent per-activity tracking prevents a single bad activity from consuming the shared cap and blocking delivery of healthy activities in the same pass. |
Refines: OX-13-006 |
OX-13-011 |
MUST | Hitting the per-pass cap for one activity MUST skip that activity for the current pass (deferring it to the next drain) without breaking the drain loop. Skipping rather than aborting keeps the drain loop running so that subsequent activities in the same pass are delivered on schedule. |
Refines: OX-13-006 |
OX-13-012 |
MUST | When a delivery attempt fails and the activity is re-queued, every later-enqueued activity that shares a recipient with it MUST be re-queued behind it in enqueue order and MUST NOT be attempted before it in that drain pass. Activities that share no recipient with the failed one are unaffected. Re-queueing a failed activity at the tail of a single FIFO lets the rows behind it overtake it, so one transient failure on ledger entry N delivers N+1… first and every recipient takes the SYNC-14 forward-gap path for a reordering the sender produced (#3878). Holding back only the rows that share a recipient keeps the per-recipient order (OX-01-005) without head-of-line blocking across recipients (OX-01-006). See ADR-0112 (docs/adr/0112-per-recipient-ordered-outbox-delivery.md). |
Refines: OX-13-011 Refines: OX-01-005 |
OX-13-013 |
MUST | When the outbox refuses an activity's sealed body before any delivery attempt (a to: naming no recipient, OX-08-003, or an object that is not inline, OX-07-001), the activity MUST be moved to the dead-letter store on that first refusal, with reason undeliverable_body, and MUST NOT be retried.The sealed body never changes (VM-08-003), so every retry is refused the same way. Retrying it spent the whole attempt budget on exponential backoff — about 24 seconds of sleep per such row — while hiding a deterministic defect behind the transient retry path. This is the local counterpart of the 4xx rule (OX-13-005). |
Refines: OX-13-008 Refines: OX-13-005 |
Participant Case Replica
Requirements for how each Vultron Actor maintains and manages its local copy (replica) of a VulnerabilityCase. Covers replica identity, bootstrap, update authority, case-context message routing, reporter case discovery, and unknown-context handling.
File ID: PCR
Unknown Case Context Handling
| ID | Priority | Requirement | Related |
|---|---|---|---|
PCR-06-004 |
MUST | The pending-activity queue for unknown case contexts MUST be actor-scoped: deferred activities for a given case ID MUST be keyed by both the receiving actor's ID and the case ID, so that replaying deferred activities after replica seeding is isolated to the correct actor's DataLayer and cannot affect a different co-located actor's case state. | Refines: PCR-06-002 |
Outbound Participant Message Routing
| ID | Priority | Requirement | Related |
|---|---|---|---|
PCR-08-004 |
MUST NOT | The CaseLedgerEntry + broadcast cascade MUST NOT be manually triggered by demo scripts or external clients. |
Refines: PCR-08-003 |
PCR-08-006 |
MUST | Unit and integration tests MUST verify that a participant's outbound activity is addressed only to the CASE_MANAGER, and that no direct peer-to-peer delivery is attempted. | |
PCR-08-015 |
MUST NOT | A received-side use case MUST NOT execute a Behavior Tree with actor_id set to any actor other than the one whose DataLayer is active for that use case invocation. |
Refines: PCR-08-010 |
PCR-08-011 |
MUST | An emit node in a case-scoped trigger BT MUST resolve the CASE_MANAGER recipient before enqueueing the activity. When case-scoped trigger routing was changed from case_addressees() to CASE_MANAGER-only routing, emit nodes that returned without setting to caused VultronOutboxToFieldMissingError to be raised deep in the outbox handler, far from the originating node. The true defect — missing CASE_MANAGER routing in the trigger path — was masked. Failing fast at the emit node surfaces the routing gap immediately and produces a diagnostic error in the correct layer (issue #927). |
Refines: PCR-08-001 — Participant outbound activities route through the CASE_MANAGER only; failure to resolve the CASE_MANAGER is a trigger-side error Refines: PCR-08-002 — Using a roster-wide list or None as the recipient in a trigger BT is the specific violation this requirement prevents |
PCR-08-016 |
MUST | An emit node in a case-scoped trigger BT MUST fail fast (return FAILURE or raise immediately) when no routable CASE_MANAGER can be found. | Refines: PCR-08-011 |
PCR-08-017 |
MUST NOT | An emit node in a case-scoped trigger BT MUST NOT silently enqueue an activity with a None or empty to field. |
Refines: PCR-08-011 |
PCR-08-018 |
MUST | Trigger use cases and their supporting BTs that cause a participant to send a case-scoped message MUST resolve the CASE_MANAGER's actor ID from the case's participant list (the participant with CVDRole.CASE_MANAGER) and use that ID as the sole to: recipient (PCR-08-005). Any helper that returns all participants (e.g. the roster index) MUST NOT be used as the sole source of recipients for outbound participant activities.A participant messages only the CASE_MANAGER (PCR-08-005). A roster-wide recipient list sends case messages to actors that are not entitled to them, and those actors act on messages that the CASE_MANAGER never committed. |
Implements: PCR-08-005 |
Testing
| ID | Priority | Requirement | Related |
|---|---|---|---|
PCR-07-001 |
MUST | Unit tests MUST verify that Announce(VulnerabilityCase) from the CASE_MANAGER creates a local replica when none exists. |
|
PCR-07-002 |
MUST | Unit tests MUST verify that Announce(VulnerabilityCase) from the CASE_MANAGER updates the local replica when one exists. |
|
PCR-07-003 |
MUST | Unit tests MUST verify that Announce(VulnerabilityCase) from an actor that is NOT the CASE_MANAGER is rejected and logged at WARNING. |
|
PCR-07-004 |
MUST | Unit tests MUST verify that the context field is used to route incoming activities to the correct case handler. |
|
PCR-07-005 |
MUST | Unit tests MUST verify that activities with an unknown case context are queued, a resync request is emitted, and the activity is dropped after timeout without updating any state. | |
PCR-07-006 |
MUST | Integration tests MUST verify the full bootstrap sequence: case creation → CASE_MANAGER sends Announce(VulnerabilityCase) → participant creates local replica → subsequent case-scoped activities are routed correctly. |
|
PCR-07-007 |
MUST | Integration tests MUST verify the late-joiner sequence: Invite → Accept → CASE_MANAGER sends Announce(VulnerabilityCase) to new participant → new participant creates local replica. |
|
PCR-07-008 |
MUST | Integration tests MUST verify that RmInviteToCaseActivity carries actor set to the CASE_MANAGER's ID, and that the invitee's RmAcceptInviteToCaseActivity is processed by the CASE_MANAGER (not the case owner). |
Verifies: PCR-08-007 Verifies: PCR-08-008 |
PCR-07-009 |
MUST | Integration tests MUST assert that no RM state-change activity is emitted with actor set to the invitee's ID from the case owner's context. |
Verifies: PCR-08-009 Verifies: PCR-08-010 |
PCR-07-010 |
MUST | Unit tests MUST verify that Announce(VulnerabilityCase) from an actor with no prior local trust record — no matching VultronReportCaseLink.case_manager_id and no invite trust anchor for the announced case ID — is rejected with a WARNING log entry and the case is NOT seeded in the receiver's DataLayer. |
Verifies: PCR-03-004 |
Participant Role Management
Defines the API for reading and mutating CVD roles on CaseParticipant, the one participant class shared by the core domain and the wire layer (ADR-0099). Requires the role-management methods, prohibits direct attribute mutation after construction, and mandates test coverage for each method.
File ID: PRM
Role Read API
Role Mutation API
Core Layer Rules
| ID | Priority | Requirement | Related |
|---|---|---|---|
PRM-03-001 |
MUST | Core code in vultron/core/ MUST NOT mutate case_roles directly on a CaseParticipant instance after construction (e.g. participant.case_roles.append(...) or participant.case_roles = [...] post-init). add_role() and remove_role() MUST be used for all post-construction role changes.Direct mutation bypasses the class's encapsulation and any future invariant checks added to the mutation methods. Centralising mutations in named methods is the standard object-oriented pattern. |
|
PRM-03-002 |
MUST | Passing case_roles=[...] as a constructor argument when creating a CaseParticipant instance is permitted and does not violate PRM-03-001; the prohibition applies only to post-construction attribute mutation.Constructor arguments are the canonical way to initialise an immutable value-object. This clarification prevents over-application of the rule. |
|
PRM-03-003 |
MUST NOT | Core code under vultron/core/ MUST NOT mutate participant_statuses directly on a CaseParticipant instance after construction (e.g. participant.participant_statuses.append(...)). The canonical mutators on CaseParticipant MUST be used. The mutator methods themselves, in vultron/core/models/case_participant.py, are exempt.ParticipantStatus exists in two incompatible shapes (SDO-03-002, ADR-0036). A wire-shaped status appended to a core-typed list reads as absent, so readers substitute an initial state — the #2232 / #2264 silent reset. validate_assignment cannot close this door because append is not an attribute assignment. Extends PRM-03-001 to participant_statuses. |
Refines: ARCH-21-001 |
Wire Layer Interface Parity
Testing Requirements
Project Documentation Structure
This specification defines the purpose, scope, and maintenance requirements for key project documentation files that guide AI agents and human developers.
File ID: PD
File Size Guidelines
Documentation Currency
| ID | Priority | Requirement | Related |
|---|---|---|---|
PD-03-001 |
MUST | notes/*.md files MUST be updated when implementation phases they describe are completed |
|
PD-03-002 |
SHOULD | Stale notes/*.md sections (cards) or whole files that are purely historical and contain no durable design guidance SHOULD be archived via append-history note to plan/history/YYMM/note/, rather than deleted. Source ID convention: NOTES-<file-stem>--<section-slug>. The learn skill Phase 5 drives section-level archival. |
|
PD-03-004 |
MUST | Notes sections (or whole files) that are fully superseded by delivered code, ADRs, or spec entries MUST be archived via append-history note to plan/history/YYMM/note/ promptly, rather than left as stale context. When all sections of a file are archived, the file MUST be removed from notes/ and its entry deleted from notes/README.md. |
|
PD-03-005 |
MUST | When archiving a notes section via append-history note, the entry body MUST include: archiving reason, superseding pointer (code path, spec ID, or docs page), --- separator, and the original section content verbatim. Source ID MUST follow the NOTES-<file-stem>--<section-slug> convention. |
|
PD-03-003 |
MUST | Module paths referenced in notes/ MUST be updated to reflect canonical current locations whenever those paths change |
|
PD-03-006 |
SHOULD | Workflow or tooling PRs SHOULD update maintainer-facing documentation in the same PR; when this is not feasible, the PR MUST include a linked follow-up docs issue before merge. | |
PD-03-007 |
MUST | When an implementation or bug-fix PR changes behavior, interfaces, or architecture described in docs/, the PR MUST include the corresponding docs/ updates or a linked follow-up type:Concern issue. Inline update is preferred for edits to existing pages, adding a single new page, or rewriting a single existing page. A deferred Concern is acceptable only when the required update would simultaneously rewrite multiple docs/ pages.Docs drift is invisible until it compounds. Updating docs alongside a change costs less than reconstructing context in a future session. Source: CONCERN-2513. |
|
PD-03-008 |
MUST | Agents MUST invoke the check-docs-sync skill on every implementation or bug-fix PR after the PR is pushed (while CI runs in the cloud), before the finalize steps (archive-history, learnings), to identify and apply any required docs/ updates in the same PR. The session MUST NOT end until the PR body's Docs: field records the outcome — the pages updated, "no docs impact" with a reason, or the deferred issue — replacing the placeholder create-pr writes.Running docs-sync after pushing lets CI and docs work proceed in parallel, reducing total wall-clock time. As a SHOULD with no record, the step was skippable without trace, and a feature PR left stale glossary prose that no later gate saw. The placeholder makes an unfinished check visible on the PR itself, where PD-03-009 enforces it. The check-docs-sync skill is embedded in build, bugfix, and learn. Source: CONCERN-2513, CONCERN-3919. |
|
PD-03-009 |
MUST | pr-triage MUST independently determine which docs/ pages an implementation or bug-fix PR's diff affects, whether or not the diff touches docs/, and compare that determination with the PR body's Docs: field. A missing or still-placeholder Docs: field is a FAIL finding; an affected page the PR neither updated nor deferred is an IMPROVE finding; a deferral MUST name an existing follow-up type:Concern issue that lists the affected pages, and is valid only where PD-03-007 permits deferring (an update that would rewrite multiple docs/ pages at once).A docs check that fires only when docs/ was already modified can never catch a page the author forgot. Only the reviewer-side check reaches hand-authored PRs and pr-execute fix rounds, and an independent determination — rather than trusting the author's Docs: line — is what catches a "no docs impact" written without looking. Source: CONCERN-3919. |
Depends on: PD-03-007 Depends on: PD-03-008 |
Documentation Build Validation
Protocol Asks and Outstanding-Ask Registers
Requirements for the protocol-ask primitive: how an actor requests something it lacks authority to do, how outstanding asks are recorded and enumerated in both directions, how asks expire, and how a receiver reports that it could not process an inbound message. Derived from ADR-0080 and the G01 planning session (CONCERN-2829).
File ID: ASK
Ask Kinds
Each kind of ask declares the replies that close it, its expiry consequence, and a default deadline.
Expiry and Reaping
How an expired ask is noticed, given that elapsed time is not an event.
Prototype Shortcuts
Permissible shortcuts and relaxed constraints for the prototype stage, supporting iterative development while preserving traceability of production requirements.
File ID: PROTO
Authentication
| ID | Priority | Requirement | Related |
|---|---|---|---|
PROTO-01-001 |
MAY | Omit cryptographic authentication for agent-to-agent communication. | Supersedes: CM-06-004 |
Federation
Performance
Production Deferral
Case Prioritization
| ID | Priority | Requirement | Related |
|---|---|---|---|
PROTO-05-001 |
MAY | Use a stub always-engage policy (AlwaysPrioritizePolicy) in place of a real prioritization framework. |
Backward Compatibility and Change Completeness
Performance Testing
Received-Side Status Authorization
Requirements for the two-gate authorization model governing how a CASE_MANAGER adopts an inbound participant's reported CaseStatus as canonical (StatusAdoptionGate) and whether to execute embargo teardown side-effects (EmbargoTeardownAuthorizationGate). Derived from ADR-0046 and IDEA-1836 planning session.
File ID: RSH
StatusAdoptionGate
Controls whether a participant's reported CaseStatus is adopted as canonical by the CASE_MANAGER.
EmbargoTeardownAuthorizationGate
Controls whether embargo teardown or other side-effects execute after a canonical CaseStatus write.
ThreatTerminationBranchNode
Node that triggers embargo teardown on any of the three threat-signal CaseStatus flags.
Conservative Default Authorization Policy for Status Gates
Both StatusAdoptionGate and EmbargoTeardownAuthorizationGate MUST default to requiring explicit Case Owner authorization before allowing PXA assertion adoption or embargo teardown side-effects. Permissive backends are valid but MUST be explicitly configured. Derived from CONCERN-2092 and ADR-0076; the mechanism is amended by ADR-0080 (CONCERN-2812, CONCERN-2809) — see RSH-07-004.
One Move, One Mover — Received-Side Write Subject and Pipeline
A protocol state transition may reach a receiver in more than one wire shape: the activity that performed the move (TentativeReject(Offer(Report))) and a declaration of the resulting state (Add(ParticipantStatus) carrying rmState). These are two layers of one move, not competing encodings. Authority is a property of the pipeline, not of the message: the mover announces, the CASE_MANAGER adjudicates and commits, replicas apply the ledger. Derived from CONCERN-3473 and ADR-0108.
| ID | Priority | Requirement | Related |
|---|---|---|---|
RSH-08-005 |
MUST | A received-side tree MUST record a received RM declaration against the sending actor (request.actor_id), never against the receiving actor whose store the tree executes in.How this codebase meets RSH-08-001; split from it so RSH-08-001 states only what other participants observe (MS-12-010). |
Implements: RSH-08-001 |
RSH-08-003 |
MUST | Every received-side tree that writes participant or case state as an effect of an inbound activity MUST perform that write only when the executing actor holds CVDRole.CASE_MANAGER for the case (BT-17-001). A non-CASE_MANAGER replica that receives such an activity directly MUST store the activity and write no participant or case state; it obtains the resulting state from the Announce(CaseLedgerEntry) broadcast (PCR-03-001). The one exception is a participant whose ledger stream is paused, which applies an embargo-ending notice sent to it by the CASE_MANAGER (CM-31-010). |
|
*Received-side trees gate their ledger commit on the CASE_MANAGER role but run their state-writing effects at every inbox, so Add(EmbargoEvent), Remove(EmbargoEvent) and Add(CaseStatus) move a replica's state from any sender, and an activity that reaches a replica by cc or misrouting is applied without the CASE_MANAGER's adjudication or sender check. PCR-03-001 already forbids this; the effects existed because the ledger did not yet carry every transition (RSH-08-004). |
|||
| * | |||
RSH-08-004 |
MUST | Every ledger semantic_type the CASE_MANAGER commits MUST have a replica apply node reachable from create_announce_log_entry_tree, or a recorded declaration that the event has no replica-side effect. Gating a received-side effect under RSH-08-003 MUST NOT land before the replay node for the transition it wrote exists. |
|
*Replicas replayed only a fixed subset of semantic types; the case-status entry committed on every embargo or public-state change, the embargo activation, and the report valid/invalid/closed and engage/defer entries had none until #3814. Removing the direct-receipt effects before the ledger carries those transitions would leave replicas blind (ADR-0108). |
|||
| * |
Whole Accept or Reject of a Received Status
A received Add(ParticipantStatus, CaseParticipant) or Add(CaseStatus, VulnerabilityCase) states a whole status, so the CASE_MANAGER accepts it as sent or refuses all of it. It never records a status the sender did not send. Derived from ADR-0107 and ISSUE-3741.
| ID | Priority | Requirement | Related |
|---|---|---|---|
RSH-09-007 |
MUST | In this codebase the fault RSH-09-006 requires MUST be emitted by the failure path of the behavior tree that applies the entry (a tree-structural guarantee), not as a side effect inside the node that applies the status. | |
*How this codebase meets RSH-09-006; split from it so RSH-09-006 states only what other participants observe (MS-12-010). It keeps the apply node a pure domain check that returns FAILURE on violation and the emission a composable side-effect, consistent with protocol-significant behavior living in the tree (BTND-01). |
|||
| * | Implements: RSH-09-006 | ||
RSH-09-008 |
MUST | When CheckParticipantStatusAcceptableNode refuses an Add(ParticipantStatus, CaseParticipant), the received use case MUST return HandlerResult.refused, which the inbox maps to a rejected InboxOutcome rather than 202 Accepted / processed. |
|
*Before ISSUE-3199, a refused status assertion returned 202 Accepted / processed because the use case did not report the BT FAILURE as a refusal, so senders could not tell a refused update from an accepted one (ISSUE-2255). |
|||
| * | Implements: RSH-09-002 |
Response Format
The Vultron protocol uses ActivityStreams activities for both requests and responses. When handlers process inbound activities, they generate response activities (Accept, Reject, Update, etc.) sent to the originating actor or other participants.
File ID: RF
Accept Response
| ID | Priority | Requirement | Related |
|---|---|---|---|
RF-02-006 |
MUST | This codebase MUST carry the Offer inline as the object of an Accept, and MUST reject a bare string ID there at construction time; the Offer is passed as an inline typed activity object (for example RmSubmitReportActivity or EmProposeEmbargoActivity).How this codebase meets RF-02-004; split from it so RF-02-004 states only what other participants observe (MS-12-010). Carrying the Offer inline rather than by reference is a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: RF-02-004 |
RF-02-007 |
MUST | This codebase MUST carry the Invite inline as the object of an Accept of an Invite, as a full inline typed activity object (for example RmInviteToCaseActivity or EmInviteToEmbargoOnCaseActivity), and MUST reject a bare string ID there at construction time.How this codebase meets RF-02-005; split from it so RF-02-005 states only what other participants observe (MS-12-010). Carrying the Invite inline rather than by reference is a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: RF-02-005 |
Reject Response
TentativeReject Response
Response Timing
| ID | Priority | Requirement | Related |
|---|---|---|---|
RF-07-001 |
MUST NOT | Response generation MUST NOT delay inbox acknowledgment |
Response Correlation
| ID | Priority | Requirement | Related |
|---|---|---|---|
RF-08-002 |
MUST | The inReplyTo field requirement MUST be enforced at the Pydantic model level via a model_validator on Accept, Reject, and other response activity classes, not by call-site convention. The validator MUST reject a response object that has a None or missing inReplyTo at construction time.Call-site convention (setting inReplyTo manually at every factory call site) is fragile: new factory paths can forget it, and there is no compile-time or model-validation enforcement. A model_validator makes the invariant intrinsic to the type and impossible to bypass silently. |
Refines: RF-08-001 |
Report Management Behavioral Requirements
Event-Condition-Action behavioral requirements for Report Management (RM). Each group is triggered by either a received RM message or a local RM state transition, and specifies typed preconditions, ordered steps, and postconditions that implementations must satisfy.
File ID: RMB
RM Write-Boundary Transition Validation
Requirements for RM state write paths to validate source→target transition validity before persisting a ParticipantStatus snapshot. Complements CSB-16 (VFD/PXA write-boundary validation) and SDO-02-002 (dimension object transition contract) by requiring that the BT write node itself is fail-closed regardless of upstream guard coverage.
| ID | Priority | Requirement | Related |
|---|---|---|---|
RMB-15-001 |
MUST | A BT node or helper that writes an RM state to a ParticipantStatus snapshot MUST validate that the (current_rm_state → target_rm_state) transition is permitted by the RM state machine using is_valid_rm_transition() before persisting. A same-state write is permitted (status confirmation). An illegal jump or backward move MUST cause the node to return Status.FAILURE without writing to the DataLayer. This is the emit-side and write-boundary rule. On the receive side the CASE_MANAGER adjudicates a peer's RM declaration under RSH-06-006 instead — a non-adjacent forward move is accepted and flagged, not refused — and reaches the write node with adjacency validation explicitly relaxed for the dimension the adjudication accepted; the write node still refuses a backward move. ParticipantStatus.rm is written via direct-construction (e.g. RmDimension(state=target)) at sites such as rm_transitions.py, bypassing the transition() contract in SDO-02-002. Upstream BT guards are necessary but insufficient: a weak or missing guard allows an invalid RM state jump to reach the write node unchecked. Validating at the write boundary makes illegal RM transitions fail-closed regardless of guard coverage. Source: concern #1896. ECA DetailsPreconditions - A non-None RM target state is passed to a ParticipantStatus write node Steps 1. [write_node] Read the participant's current RM state from the DataLayer → Current RM state available 2. [write_node] If target == current, proceed. If is_valid_rm_transition(current, target) is True, proceed. Otherwise return Status.FAILURE. → Only valid transitions or same-state writes reach the DataLayer Postconditions - No ParticipantStatus with an illegal RM state jump is persisted |
Implements: SDO-02-004 Refines: BTND-10-001 |
Semantic Extraction
The inbox handler extracts semantic meaning from ActivityStreams activities by matching activity type and object type patterns. This semantic type determines which handler function processes the activity.
File ID: SE
Pattern Matching
| ID | Priority | Requirement | Related |
|---|---|---|---|
SE-01-001 |
MUST | The system MUST match activities using activity type and object type patterns | |
SE-01-005 |
MUST | The semantic extraction algorithm MUST rehydrate a URI string reference before pattern matching, and MUST handle both fully expanded objects and unresolved URI strings defensively. How this codebase meets SE-01-002. Split from SE-01-002 so it states only the rule ActivityStreams sets (MS-12-010). |
Implements: SE-01-002 |
SE-01-003 |
MUST | If rehydration fails for any nested object, the system MUST: (1) log a WARNING identifying the unresolvable URI, (2) return MessageSemantics.UNKNOWN for the containing activity, and (3) store the activity in a dead-letter record per SE-04-003. | |
SE-01-004 |
MUST | Pattern matching MUST support type/subclass matching semantics for actor/object classes | Depends on: SE-01-002 |
Semantic Type Assignment
Pattern Registry
| ID | Priority | Requirement | Related |
|---|---|---|---|
SE-03-001 |
MUST | SEMANTIC_REGISTRY MUST contain an entry with a non-None pattern for every supported MessageSemantics value except the UNKNOWN and UNKNOWN_UNRESOLVABLE_OBJECT sentinelsPhrased as "an entry with a non- None pattern" rather than "a key", because SEMANTIC_REGISTRY is an ordered list of SemanticEntry values rather than the MessageSemantics → ActivityPattern dict it replaced. Both sentinels do have registry entries; what distinguishes them is that their pattern is None, since they are resolved by fallback logic in find_matching_semantics() rather than by pattern iteration. |
|
SE-03-002 |
MUST | Pattern registry MUST be ordered from most specific to least specific | |
SE-03-003 |
MUST | Every ActivityPattern entry in SEMANTIC_REGISTRY MUST discriminate on at minimum both Activity type AND Object typeA pattern without an object-type constraint would match every activity of that verb type regardless of payload, causing misrouting when multiple semantics share an outer Activity type |
|
SE-03-004 |
MUST | An ActivityPattern MUST NOT discriminate on the sender's identity or role: it matches only the activity's type and the types of its object, target, context and recipients. When the CASE_MANAGER performs the same verb on the same kind of object as a participant, its activity MUST match the participant's pattern, and whether the sender may send it MUST be decided after routing, by the handler's sender-entitlement check (HP-01-006, HP-01-007).A semantic type says what an activity is; the sender's standing says whether it is permitted (ADR-0152). A matcher that read the sender would need to know who holds the CASE_MANAGER role for each case, and would give one shape two meanings depending on who sent it. |
Depends on: HP-01-006 |
Unrecognized Activity Handling
Pattern Validation
| ID | Priority | Requirement | Related |
|---|---|---|---|
SE-05-001 |
MUST | All patterns MUST have corresponding MessageSemantics enum value | |
SE-05-002 |
MUST | A pattern's semantic type MUST have a use-case class in the mapping returned by use_case_map() when, and only when, its SEMANTIC_REGISTRY entry is handled on receipt. An entry whose use_case_class is None is recognized but not handled: the dispatcher MUST report a received activity of that type as "no use case found", a REFUSED verdict logged at INFO with the activity and actor IDs, and MUST NOT raise, send a ProcessingFault or treat the activity as unrecognized (SE-04).Recognizing a semantic type does not imply a handler for it (ADR-0140 item 5). Several CASE_MANAGER shapes are written to the ledger but never handled when received (ADR-0152), and the registry still has to label them. "No use case found" says the receiver does not handle the message, not that the message is invalid, so it is not answered with a fault. No handler ran, so the verdict is not processed (UCORG-05-012). |
Refines: UCORG-05-012 |
SE-05-003 |
MUST | All patterns MUST have unit test coverage |
Registry Order Enforcement
| ID | Priority | Requirement | Related |
|---|---|---|---|
SE-06-001 |
MUST | The ActivityPatternRegistry MUST include an import-time guard function _validate_registry_order() that raises RegistryOrderError if any registered ActivityPattern appears in the registry in an order that would cause a more-specific pattern to be shadowed by a less-specific one that matches a superset of the same activities.py_trees traverses the registry in insertion order; a less-specific pattern inserted before a more-specific one will match first and the specific case is never reached. This is a silent correctness bug that manifests only for the specific activity combinations the shadowed pattern was meant to handle. |
Refines: SE-05-001 |
SE-06-002 |
MUST | RegistryOrderError MUST be a named exception class in the semantic extraction module. It MUST NOT be raised as a generic ValueError or AssertionError. |
Implements: SE-06-001 |
SE-06-003 |
MUST | The registry order guard MUST have a companion unit test that asserts correct ordering is maintained for all registered patterns. The test MUST fail if the guard raises RegistryOrderError on the current registry. |
Implements: SE-06-001 |
Display Metadata
| ID | Priority | Requirement | Related |
|---|---|---|---|
SE-07-001 |
MUST | Every SemanticEntry in SEMANTIC_REGISTRY MUST carry a non-empty phrase: str field containing an active-voice format-string template suitable for human-readable event summaries (e.g. "{actor} submitted report {object}").A mandatory phrase field ensures every registered semantic is self-documenting and prevents silent drift between the registry and display-layer phrase tables (see CONCERN-1675). Making the field mandatory at the dataclass level means a missing phrase is a construction-time error, not a runtime rendering gap. |
|
SE-07-002 |
MUST | Phrase templates MUST use named slots drawn from the set {actor}, {object}, {target}, {context}, {origin}, {inner_object}. Slot names MUST be human-facing role identifiers, not Python field names from VultronEvent.Runtime population note (CONCERN-1898): The current render pipeline ( CaseTimelineEvent.summary, event_phrase()) only populates {actor}, {object}, and {target}. The slots {context}, {origin}, and {inner_object} are reserved for future pipeline extensions but are never populated today. A phrase referencing an unpopulated slot will render with a dangling "—" fallback. SE-07-005 pins this invariant with an allowlist test. |
Refines: SE-07-001 |
SE-07-003 |
MUST | The SemanticEntry dataclass phrase field MUST have no default value so that omitting it is a construction-time TypeError, not a silent empty string. |
Implements: SE-07-001 |
SE-07-004 |
MUST | A parametrized unit test MUST assert that every entry in SEMANTIC_REGISTRY has a non-empty phrase and that calling entry.phrase.format_map(d) with a defaultdict(lambda: "X") returns a non-empty string without raising a KeyError. |
Verifies: SE-07-001 |
SE-07-005 |
MUST | A unit test MUST assert that no phrase in SEMANTIC_REGISTRY references a slot name outside the set currently populated by the runtime render pipeline ({actor}, {object}, {target}). This test MUST fail if a phrase is added that uses {context}, {origin}, {inner_object}, or any other slot name not listed here, until the render pipeline is extended to populate that slot.The defaultdict-based SE-07-004 test fills every slot with a placeholder, so it cannot detect phrases that reference slots the runtime never fills. SE-07-005 adds a structural slot-name check, making unfillable-slot bugs a CI failure rather than a silent render defect. Source: CONCERN-1898. |
Verifies: SE-07-002 Refines: SE-07-004 |
SE-07-006 |
MUST | Companion behavioural tests MUST call event_phrase() and CaseTimelineEvent.summary with representative MessageSemantics values covering each distinct slot shape (actor-only, actor+object, actor+target) and assert: (1) no trailing "—" appears in the rendered output; (2) no un-substituted {slot} markers remain.The structural allowlist (SE-07-005) catches phrase definitions that use forbidden slot names, but cannot detect a phrase that is structurally valid yet still produces a dangling em-dash at render time (e.g. because target_label did not resolve for a particular semantic type). The behavioural tests close this gap. Source: CONCERN-1898. |
Verifies: SE-07-002 Refines: SE-07-005 |
Target-Field Discriminators
| ID | Priority | Requirement | Related |
|---|---|---|---|
SE-08-001 |
MUST | When two or more ActivityPattern entries share the same activity_ type AND object_ type, the more-specific pattern MUST include an additional discriminator field (e.g. target_, context_) that unambiguously distinguishes it from the less-specific pattern. The more-specific pattern MUST appear before the less-specific one in SEMANTIC_REGISTRY.Without an additional discriminator, two patterns sharing the same verb and object type are indistinguishable; the first matching entry always wins. _validate_registry_order() enforces ordering at import time. |
Refines: SE-03-002 |
SE-08-002 |
MUST | A wire activity class whose semantic identity depends on a target-field discriminator MUST declare that target field as required (not optional) so that a sender omitting it produces a validation error rather than a silently misrouted activity. Declaring the discriminator field as required ensures that any attempt to construct the activity without it fails fast at the sending side rather than reaching the receiver's registry as an ambiguous shape. |
Refines: SE-08-001 |
SE-08-004 |
MUST | When an ActivityPattern uses target_ as the sole field that distinguishes it from a less-specific competing pattern, strict=True MUST be set on that pattern so that a bare URI string in the target field does NOT match the typed target constraint.ActivityPattern._match_activity_field treats a bare string as matching in permissive mode (strict=False). Without strict=True, an unresolved target URI would cause the more-specific pattern to match every competing shape regardless of whether the target is the right type, making registry ordering the sole protection against misrouting and defeating the discriminator's purpose. See CONCERN-2322. |
Refines: SE-08-001 |
Spec Registry
Requirements for the structured spec registry: YAML spec files governed by Pydantic models, with linting, pytest integration, and context-generation tooling. The registry provides a single source of truth for spec IDs, prefixes, titles, and cross-reference validation.
File ID: SR
YAML File Format
Pydantic Schema
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-02-001 |
MUST | The vultron/metadata/specs/ module MUST define Pydantic models for all registry structures: SpecFile, SpecGroup, StatementSpec, BehavioralSpec, Relationship. |
|
SR-02-002 |
MUST | Fixed vocabularies MUST be implemented as StrEnum subclasses: RFC2119Priority, RelationType, SpecKind, Scope, SpecTag, AdrStatus. |
|
SR-02-003 |
MUST | RFC2119Priority MUST include at minimum: MUST, MUST_NOT, SHOULD, SHOULD_NOT, MAY. |
|
SR-02-004 |
MUST | RelationType MUST include at minimum: implements, supersedes, extends, depends_on, conflicts, refines, derives_from, verifies, part_of, constrains, satisfies. |
|
SR-02-005 |
MUST | SpecKind MUST include all five portability tiers: protocol, foundation, architecture, project, process. |
|
SR-02-006 |
MUST | Scope MUST include: prototype, production. |
|
SR-02-007 |
MUST | SpecTag MUST be a controlled vocabulary StrEnum, defined in vultron/metadata/specs/schema.py. |
|
SR-02-008 |
MUST | A SpecIdStr type alias MUST enforce the ID pattern ^[A-Z]{2,8}(-\d{2}(-\d{3}[a-z]?)?)?$ via Pydantic StringConstraints. The optional lowercase letter suffix (e.g., LST-05-002b) designates a sub-requirement split from a parent spec. |
|
SR-02-009 |
MUST | StatementSpec MUST have fields: id (SpecIdStr), priority (RFC2119Priority), kind (SpecKind, required), statement (non-empty str), rationale (non-empty str), and optional fields: testable, scope, tags, relationships, adr, lint_suppress.These are the fields every consumer of the corpus reads, from spec-lint to spec-dump, so the schema is what guarantees each requirement has them. A required rationale keeps the reason for each rule next to the rule (MS-10-012). |
|
SR-02-010 |
MUST | BehavioralSpec MUST extend StatementSpec with optional fields: preconditions, steps, postconditions. |
|
SR-02-011 |
MUST | The lint_suppress field on a spec MUST accept a list of LintWarningCode StrEnum values to suppress named linter warnings for that spec. |
|
SR-02-012 |
MUST | SpecGroup MUST have fields: id (SpecIdStr), title, specs (non-empty list), and optional description, scope, trigger. SpecGroup MUST NOT have a kind field; kind is required on each individual spec item. |
|
SR-02-013 |
MUST | SpecFile MUST have fields: id (str), title, description, scope (required), groups (non-empty list), and optional tags. SpecFile MUST NOT have a kind field; kind is required on each individual spec item. SpecFile MUST NOT have a version field, so that its extra="forbid" configuration (SR-02-022) rejects a file that declares one (SR-01-003). |
|
SR-02-014 |
MUST | scope at SpecFile and SpecGroup level MUST serve as a default; individual specs MAY override it. kind MUST be declared on every individual spec item and is not inheritable from file or group. |
|
SR-02-015 |
SHOULD | Relationship SHOULD include an optional note field for free-text annotation of the relationship. |
|
SR-02-016 |
SHOULD | BehaviorStep SHOULD include fields: order (int), actor (str), action (str), and optional expected (str). |
|
SR-02-017 |
MUST | The spec schema MUST define a TriggerType StrEnum enumerating the categories of behavioral trigger: message_received (a protocol message type), state_entered (an RM, EM, or CS state value), and scenario_start (a named demo or integration scenario). Enumerating kinds allows conformance tooling to classify trigger groups without parsing prose. |
|
SR-02-018 |
MUST | A Trigger object MUST carry a type: TriggerType field and a value string naming the specific message type (e.g. EP) or protocol state (e.g. RM.VALID) that activates the behavior; for scenario_start triggers value names the scenario (e.g. rv, rvv). |
|
SR-02-019 |
MUST | A Precondition object MUST carry a description string summarising the precondition in prose; MAY carry rm_state (list of RM states), em_state (list of EM states), role (list of CVDRoles), and/or cs_pattern (a CS hypercube pattern string) as typed fields enabling conformance tooling to evaluate the precondition by matching against protocol state-machine enums directly. |
|
SR-02-020 |
SHOULD | A spec item SHOULD use the optional adr field (list of AdrIdStr, pattern ^ADR-\d{4}$) to record structured ADR provenance; the export emits each as a derives_from edge to the ADR ID so an amended ADR can be traced to its dependent specs via the edges graph, and the linter hard-errors on a dangling target.Prose citations in rationale are invisible to the edges graph, so "which specs inherit this ADR's premise?" was not a mechanical query. The structured field closes that gap (MS-11-004). See ADR-0043. |
|
SR-02-021 |
MUST | StatementSpec (and by inheritance BehavioralSpec) MUST support the following optional fields in addition to the core set: verification (NonEmptyStr — machine-executable or human-reviewable check that confirms the requirement is met), note (NonEmptyStr — correction notice or clarifying annotation), tracking_issue (str — GitHub issue reference(s) tracking planned implementation), verification_debt (#N — the issue that owns verifying a MUST-tier requirement with no verification, MS-10-006), references (list[str] — file paths or URIs to supporting documents), exceptions (list[NonEmptyStr] — permitted exceptions to the rule stated in the requirement), trigger (Trigger — activating event for a behavioral spec item), deprecated (bool, default False), and superseded_by (SpecIdStr | None — ID of the replacement requirement).These fields were found in widespread use across the spec corpus but were absent from the formal schema, causing them to be silently ignored rather than validated. Formalising them closes that gap and enables tooling to surface deprecation warnings, link tracking issues, and verify execution steps. deprecated/superseded_by mirror the ADR supersession pattern (MS-14-001) applied at the individual requirement level. |
Refines: SR-02-009 |
SR-02-022 |
MUST | Every Pydantic model in vultron/metadata/specs/schema.py (Relationship, Trigger, Precondition, BehaviorStep, Postcondition, StatementSpec, BehavioralSpec, SpecGroup, SpecFile) MUST declare model_config = ConfigDict(extra="forbid").Without extra="forbid", unrecognised YAML keys are silently ignored. This allows schema drift to accumulate undetected — the deprecated/superseded_by incident being the immediate trigger. Hard-failing on unknown fields converts silent data loss into a validation error that forces explicit schema evolution. |
Refines: SR-02-009 |
Registry Loader
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-03-001 |
MUST | The vultron/metadata/specs/ module MUST provide a load_registry(spec_dir) function that loads all *.yaml files from the given directory into a SpecRegistry. |
|
SR-03-002 |
MUST | SpecRegistry MUST maintain an index of all specs by ID and enforce global uniqueness, raising ValueError on duplicate IDs. |
|
SR-03-003 |
MUST | SpecRegistry MUST provide a get(spec_id) method returning the Spec for a given ID, raising KeyError on unknown IDs. |
|
SR-03-004 |
MUST | SpecRegistry MUST provide a validate_cross_references() method returning a list of error strings for any dangling relationship targets. |
|
SR-03-005 |
MUST | The loader MUST work regardless of the caller's working directory by accepting an explicit spec_dir path. |
|
SR-03-006 |
SHOULD | SpecRegistry SHOULD maintain a group index in addition to the spec index. |
|
SR-03-007 |
SHOULD | The loader SHOULD provide a _find_repo_root() helper (mirroring the notes loader) for callers that do not supply an explicit path. |
|
SR-03-008 |
MUST | load_registry MUST attribute a failed file to its path per MS-17-001, and MUST obtain that attribution from the shared helper named by MS-17-003 rather than its own wrapper. This applies to both the YAML parse and the SpecFile validation of each file.load_registry backs every spec consumer — the linter, the pre-commit hook, spec-dump, the coverage report, the docs renderer, the pytest marker check — so an unattributed failure here is the one an author is most likely to meet. Attributing the parse alone would leave the more common fault unnamed: an invalid enum value (kind, priority, rel_type) is reported as a positional path into the parsed structure, which locates a requirement no better than a bare line number does. |
Implements: MS-17-001 Implements: MS-17-003 |
SR-03-009 |
MUST | When more than one file in specs/ fails to load, load_registry MUST report every failing file in a single error rather than stopping at the first, and MUST carry the individual failures as structured data on the exception rather than only as a joined message.The loader rejects the corpus as a unit, so a caller told one reason at a time gains nothing from the round trip — the principle EH-07-001 states for validation boundaries. The linter that wraps this loader already collects and prints all of its own findings together, so stopping at the first file makes the loader the one part of the tool that reports less than it knows. The case that matters is a bulk spec edit breaking several files at once. |
Derives from: EH-07-001 — applies the report-every-violation rule to a file-walking loader |
Linter
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-04-001 |
MUST | The vultron/metadata/specs/lint.py module MUST provide a lint(spec_dir) function that returns exit code 0 (clean) or 1 (errors found). |
|
SR-04-002 |
MUST | The linter MUST report as hard errors: duplicate spec IDs, dangling relationship targets, group-ID/file-prefix mismatches, and invalid YAML structure. | |
SR-04-003 |
MUST | The linter MUST be invokable both as uv run spec-lint (console entry point) and as python -m vultron.metadata.specs.lint [spec_dir] (module invocation). |
|
SR-04-009 |
MUST | Both invocation forms of the spec linter MUST produce identical output for the same input. | Refines: SR-04-003 |
SR-04-010 |
MUST | When spec_dir is omitted from the module invocation, the linter MUST default to specs/ relative to the current working directory. |
Refines: SR-04-003 |
SR-04-004 |
MUST | The linter MUST respect lint_suppress fields on individual specs when checking advisory warnings. |
|
SR-04-005 |
SHOULD | The linter SHOULD emit advisory warnings (non-blocking) for: specs with testable: false and no behavioral steps (suppressible via lint_suppress: [testable_without_steps]), rationale text exceeding 500 characters, and specs with no tags. |
|
SR-04-006 |
SHOULD | The linter SHOULD check that tag values in spec entries are members of the SpecTag StrEnum vocabulary. |
|
SR-04-007 |
SHOULD | The linter SHOULD check that scope at the spec level is not broader than the containing group or file scope. |
|
SR-04-008 |
MUST | Every spec ID cited in a Python source file (production code, test file, or docstring/comment) MUST resolve to an entry in the spec registry; a citation of a non-existent spec ID is a hard error caught by the pre-commit lint gate (_check_phantom_spec_id_citations). Files under test/metadata/specs/ are exempted because they legitimately use synthetic fixture IDs.When no spec entry exists for the described behaviour, prefer a clear prose description over inventing a spec ID. A phantom citation misleads readers more than no citation at all — it converts an undocumented choice into an apparently settled requirement, forcing the next reviewer to disprove a mandate rather than evaluate a decision (see issue #2468). |
|
SR-04-011 |
MUST NOT | A module under vultron/ MUST NOT cite a requirement from the Testability topic (TB- prefix) as one it implements. Implements: citations are topic-scoped: resolving in the registry (SR-04-008) is necessary but not sufficient, and a resolving citation of a topic that governs tests is a defect, not traceability.Every ID cited under vultron/ resolves to some requirement, so an existence-only check (SR-04-008) cannot see a wrong-topic citation. When the trigger spec was renamed from TB to TRIG, the Testability topic kept the TB prefix and the trigger routers went on citing "the system MUST use pytest" 236 times as the requirement they implement, every one of them resolving (#3354, #3829). The check is topic-scoped rather than resolution-scoped so the same drift cannot recur silently. |
Pytest Integration
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-05-001 |
MUST | The project MUST register a spec pytest marker in conftest.py: @pytest.mark.spec("SR-01-001"). |
|
SR-05-002 |
MUST | pytest_collection_modifyitems MUST validate each spec marker value against the loaded SpecRegistry and emit UnknownSpecIdWarning (a UserWarning subclass, non-blocking per SR-05-007) for any unknown spec ID.Previously specified as a PytestWarning. Nothing under vultron/ imports pytest, and the warning classes live in vultron/metadata/specs/ so that filterwarnings can target them by a stable dotted path — conforming to the old wording would have pulled a test dependency into production code. The code has always raised a UserWarning subclass; this corrects the spec to the implementation rather than the reverse. The "non-blocking" clause is split out to SR-05-007 because it depends on a filter-ordering property that is easy to get backwards and was in fact wrong in pyproject.toml from the marker gate's introduction until #3336. |
|
SR-05-003 |
SHOULD | The pytest integration SHOULD load the registry once per session (not per test) to avoid I/O overhead. | |
SR-05-004 |
MUST | Tests that verify a requirement every implementation must obey (spec items with kind equal to "protocol" or "foundation") MUST carry @pytest.mark.spec("<ID>") linking the test to the requirement it exercises. Tests for other requirement kinds SHOULD use the marker to enable coverage reporting by spec ID.Protocol- and foundation-kind requirements are conformance-critical. Without a marker, coverage for those requirements is unverifiable and the CI coverage guard (SR-05-005) cannot enforce it. foundation is covered with protocol because every rule an implementer must obey needs a test, whether Vultron defines it or inherits it (ADR-0134). The SHOULD for other kinds preserves opt-in adoption for lower-stakes requirements. |
|
SR-05-005 |
MUST | No pull request may add an uncovered protocol or foundation requirement: CI MUST fail a pull request when a kind: protocol or kind: foundation ID that no @pytest.mark.spec marker covers at the PR head was covered, or was of neither kind, at the merge base with the base branch, and MUST list each such ID. A requirement that leaves both kinds or is retired is not an addition, and the check MUST NOT compare against a committed count.A SHOULD-only marker rule produced near-zero adoption with no enforcement signal. Markers record coverage per item, so the check compares the uncovered set at the base with the set at the head and commits no number: a one-sided ceiling collects silent slack and a two-sided pin races every concurrent PR ( notes/testing-pitfalls.md). spec-coverage prints the live uncovered total. foundation is guarded with protocol because every rule an implementer must obey needs a test (ADR-0134), and treating the two as one set means a relabel from protocol to foundation is not a way around the guard. |
|
SR-05-006 |
MUST | pytest_collection_modifyitems MUST distinguish three cases when resolving the registry, and MUST NOT conflate them. (a) No corpus — specs/ absent, or present with no spec files: return silently, because there is nothing to validate against and nothing is wrong. (b) Unloadable corpus — the directory exists but load_registry raises: emit SpecRegistryUnavailableWarning (non-blocking per SR-05-007) naming the spec directory, the underlying cause, and the requirement whose gate is down, then return without validating markers. (c) Unexpected failure — an exception type outside the documented load-failure set: do not catch it. The except clause MUST name the narrowest concrete types that a corpus which exists but does not load can raise (CS-23-001); a blanket except Exception is a defect.A silently skipped gate reports the same clean pass as a corpus with no unknown IDs in it, so one malformed spec file disabled SR-05-002 validation for a whole session with no warning and no change in exit code (#3331). Measurement showed the broad catch absorbed nothing on a healthy corpus and exactly one bug-shaped cause on a corrupted one — the signature CONCERN-3295 describes. The session must still be allowed to continue: aborting it would mean a malformed spec file blocks the very tests that diagnose it, which is why (b) warns rather than raises. Case (c) is separated because an unexpected type means a bug in the loader, not a bad spec file. |
Refines: SR-05-002 — SR-05-002 specifies the marker check; SR-05-006 specifies what happens when the registry it needs is unavailable Refines: CS-23-001 — applies the narrow-catch rule to the pytest collection hook, which sits outside CS-23-001's vultron/ scope |
SR-05-007 |
MUST | Every warning the pytest integration relies on being non-blocking MUST have a matching always:: entry in pyproject.toml's filterwarnings, and that entry MUST be listed after the "error" entry. pytest applies ini filterwarnings in list order via warnings.filterwarnings(), which inserts each filter at index 0, so a later entry outranks an earlier one; an exemption placed before "error" is a no-op. Conformance MUST be verified behaviourally — by emitting the warning under the project's real filter list in a sub-session — and not only by asserting the entry's position in the ini list.The ordering is counter-intuitive and was recorded backwards in the marker gate's original implementation notes ("placed BEFORE error so the specific rule takes precedence (Python prepend semantics)"). The consequence was that SR-05-002's non-blocking guarantee never held: an unknown spec ID aborted collection instead of warning. #2329 measured the correct rule but the finding was not applied, and #3336 then reproduced the same mistake for a second warning class — which is why the verification method is normative here. A position assertion alone passes while the behaviour is broken, because pytest.warns and catch_warnings both replace the ini filters and so cannot observe an escalation. |
Refines: SR-05-002 — supplies the "non-blocking" mechanism SR-05-002 and SR-05-006 both depend on |
Pre-commit Hook
Context Generation Tool
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-07-001 |
MUST | The vultron/metadata/specs/ module MUST provide a context generation tool that accepts a SpecRegistry and produces output for agent consumption. |
|
SR-07-002 |
MUST | The tool MUST support a markdown renderer that regenerates a .md view of a spec file from its YAML source. |
|
SR-07-003 |
MUST | The tool MUST support a JSON exporter that serializes the full registry or individual spec files to JSON. | |
SR-07-004 |
SHOULD | The markdown renderer SHOULD produce output compatible with the existing meta-specifications.md style guide. |
|
SR-07-005 |
SHOULD | The JSON exporter SHOULD support filtering by kind, scope, tags, or priority to allow agents to request only relevant subsets. |
|
SR-07-006 |
SHOULD | The spec-dump CLI entry point SHOULD support a --kind flag accepting one or more comma-separated SpecKind values, filtering the LLM-optimized JSON output to matching requirements. An unknown kind value MUST produce a clear error message listing valid values and exit with code 2. |
|
SR-07-007 |
MUST | The spec-dump CLI MUST support an --index flag that prints a plain-text spec map instead of JSON: one line per topic (ID Title (N reqs)) followed by one indented line per group (GROUP-ID group title (N)), counting only requirements that match the other flags.The unfiltered JSON dump is several megabytes, far larger than an agent context window. A map of topics and groups lets an agent choose what to load before loading it. |
|
SR-07-008 |
MUST | The spec-dump CLI MUST support the selectors --topic, --group, and --ids (each a comma-separated list) and a --deps flag that adds the transitive dependencies of every selected requirement; the selectors MUST combine as a union.Targeted loads replace the full dump. A union lets one call load a primary topic together with specific groups or IDs from other topics. |
|
SR-07-009 |
SHOULD | The spec-dump CLI SHOULD expose --tag (comma-separated; all tags must match), --scope, and --priority flags that narrow the selected requirements, alongside --kind. |
|
SR-07-010 |
MUST | The spec-dump CLI MUST support a --cross-cutting flag that adds the topics in the CROSS_CUTTING_TOPICS constant of vultron.metadata.specs to the selection; that constant MUST be the single definition of the cross-cutting topic list.Skills previously repeated the cross-cutting list by hand, so the copies could drift. Skills now reference the flag. |
|
SR-07-011 |
MUST | The spec-dump CLI MUST exit with code 2 and write an error to stderr that names the offending values when a --topic, --group, --ids, --tag, --scope, or --priority value is unknown, or when a flag is given without its value.An empty result for a mistyped ID looks the same as "no requirements apply", which is the wrong conclusion for an agent to draw silently. |
|
SR-07-012 |
SHOULD | The spec-dump CLI SHOULD support a --slim flag that reduces each requirement to id, priority, statement, and note (when set), keeps only relationship edges whose both ends are in the output.note stays because it records which side an obligation binds; dropping it leads to requirements implemented at the wrong layer. |
|
SR-07-013 |
SHOULD | When spec-dump is run without --index and without any selector or filter flag, it SHOULD still print the full JSON dump to stdout and SHOULD write a one-line warning to stderr that states the output size and suggests --index and the selector flags.spec-audit needs the full corpus, but an agent that runs the bare command by habit needs to learn that the output does not fit in its context. |
|
SR-07-014 |
MUST | The spec-dump CLI MUST support a --text flag that renders the selected requirements as indented text — a topic header, a group header, then one ID PRIORITY statement line per requirement — and MUST reject --text without a selector with exit status 2.The JSON dump is a single line, so an agent that prints a load of any size sees a truncated prefix of one unreadable token; observed agent runs piped every load through jq to read it at all. One line per requirement truncates gracefully and costs fewer bytes than the JSON. Requiring a selector keeps --text from reproducing the full-dump wall. |
|
SR-07-015 |
MUST | The selection that --text requires MUST be a set selector (--topic, --group, --ids, --adr, or --cross-cutting) naming at least one value; an attribute filter such as --kind or --priority MUST NOT satisfy it, and a set selector that names no values (--topic "") MUST exit with status 2.Attribute filters keep whole topics, so counting one as a selection let --text --kind protocol print 285 KB — the wall SR-07-014's selector requirement exists to prevent. An empty list is the opposite failure: it means "select nothing" to the exporter but "no selection" to the size guard, so --topic "" printed an empty dump under a warning about an unfiltered one. Both are typos, and both should say so. |
Refines: SR-07-014 |
SR-07-016 |
MUST | The glossary-index entry point MUST render one line per ## section of docs/reference/glossary.md carrying that section's line range, its terms, and the aliases to avoid for each term, and MUST attribute a term table to the section whose line range contains it rather than to a section with a matching heading.The glossary is ~82 KB, most of it definitions a given task never touches; the index is ~12 KB and carries the naming guard, which is the part every session needs. Line ranges are what let deepen-context read one section instead of the file. Matching on heading text instead of position gave two sections sharing a title each other's terms. |
|
SR-07-017 |
MUST | The spec-dump CLI MUST support an --adr set selector (a comma-separated list of ADR-NNNN ids) that selects every requirement whose adr: field names one of them, and that combines with the other selectors as a union. A value that is not an ADR-NNNN id, or that names no record under the docs/adr/ beside the spec directory (archived records included), MUST exit with status 2 and name the value. For each named record that no requirement cites in adr:, it MUST write a note to stderr that names the record and points at its prose citations under specs/ and notes/, and MUST still print the selection.deepen-context routes through decision records by reading the adr: edge backwards instead of reading a record body (ADR-0127, MS-11-008), and agents do not read raw spec YAML. Some live records are reached only by prose citation, so an empty selection for one of them would read as "this record governs nothing"; the note says where its rule is stated instead. |
Refines: SR-07-008 |
Docs Renderer Kind Routing
Migration
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-08-001 |
MUST | All existing specs/*.md files MUST be migrated to specs/*.yaml using a lossless conversion that preserves all requirement text, IDs, and relationships. |
|
SR-08-003 |
MUST | YAML files in specs/ are the sole authoritative source for project specifications. No other format or location holds normative spec content. |
|
SR-08-004 |
MUST | specs/README.md MUST be retained as Markdown; it is documentation, not a requirement file.specs/meta-specifications.md has been converted to specs/meta-specifications.yaml (TASK-SPECMD). Only specs/README.md remains as a Markdown file. |
|
SR-08-005 |
MUST | All in-project skills, prompts, and agent instructions that reference specs/*.md files by path or extension MUST be updated to use specs/*.yaml paths or the context-generation tool output before the migration is considered complete. |
|
SR-08-006 |
MUST NOT | The original specs/*.md requirement files MUST NOT be deleted until the YAML files have passed the spec linter with zero errors. |
|
SR-08-008 |
MUST NOT | The original specs/*.md requirement files MUST NOT be deleted until all agent and skill references have been updated to use specs/*.yaml paths. |
Refines: SR-08-005 |
SR-08-007 |
SHOULD | A migration script SHOULD be provided to convert a single specs/*.md file to the YAML format as a starting point; manual review and cleanup is expected. |
Story Traceability
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-11-001 |
MUST | StatementSpec MUST support an optional stories field containing a non-empty list of StoryIdStr values (pattern ^story_\d{4}_\d{3}$), identifying the user stories that motivated the requirement. The field MUST be subject to the _nonempty_if_present validator.Bidirectional traceability between requirements and stakeholder user stories is a NASA systems engineering criterion. Without back-references in spec files, tracing "which stakeholder need does this requirement serve?" requires consulting the manually-maintained docs/reference/user_stories/traceability.md matrix, which drifts as specs evolve. Embedding story references in the spec files makes the spec the authoritative source and enables automated change-impact analysis. |
|
SR-11-002 |
MUST | Story ID strings used in stories: fields MUST conform to the pattern ^story_\d{4}_\d{3}$ (e.g. story_2022_001). The StoryIdStr type in vultron/metadata/specs/schema.py MUST enforce this constraint via Pydantic StringConstraints. |
Refines: SR-11-001 |
Spec Selection Backstop
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-12-001 |
MUST | The spec-backstop CLI MUST, by default, treat as changed every .py file in the git diff between the merge-base of --base (default origin/main) and HEAD and the working tree, plus every untracked .py file; the --paths flag MUST replace that set with the named files, each treated as fully changed.Agents choose which requirements to load by judgment. A check computed from the diff catches under-selection without trusting that judgment. The working tree is included because an agent runs the check before it commits. |
|
SR-12-002 |
MUST | spec-backstop MUST treat as a changed symbol each top-level function, class, or assignment whose line span, decorators included, intersects a diff hunk; a changed method MUST count as a change to its class, and a fully changed file MUST contribute all of its top-level symbols.Symbol-level matching is much narrower than module-level matching. A module such as vultron/core/behaviors/bridge.py is imported by dozens of test files, but only the tests that import the edited names govern the edit. |
|
SR-12-003 |
MUST | spec-backstop MUST report as required (MUST tier) the spec groups of the @pytest.mark.spec IDs in test files that import a changed symbol by name, in mirror-path test files of a changed module, and in the changed parts of changed test files, together with the groups of requirements whose statement names a changed module's path or dotted name, or a changed symbol of six or more characters or in CamelCase, as a whole word.Each signal is a deterministic link between the change and a requirement. Directory prefixes and short symbol names are excluded because they match too many unrelated statements. |
|
SR-12-004 |
SHOULD | spec-backstop SHOULD report as advisory (INFO tier) the spec groups of test files that import a changed module by any name, and SHOULD omit from that tier every group that is already in the MUST tier.A module import is weak evidence. Listing it without enforcing it gives the agent a place to look without adding false alarms. |
|
SR-12-005 |
MUST | spec-backstop MUST report each hit at the spec-group level, listing the requirement IDs that matched and the deduplicated evidence for them, and MUST emit the same report as JSON when --json is given.Agents load specs by topic and group, so a group-level report maps directly onto the manifest. The evidence lets the agent judge a hit instead of loading it blindly. |
|
SR-12-006 |
MUST | When --manifest names a file, or - for stdin, spec-backstop MUST read the topic, group, and requirement IDs on the manifest's Loaded lines as loaded and on its Considered, skipped lines as dismissed, and MUST exit with code 1 and list every MUST-tier group that is not resolved; a group is resolved when it or its topic is loaded, when it is dismissed, or when each of its matched requirement IDs is listed.A dismissal with a reason is an explicit judgment and satisfies the check; a group that the manifest does not mention at all is the under-selection the check exists to catch. |
|
SR-12-007 |
MUST | spec-backstop MUST write to stderr a note naming each changed source file that produced no MUST-tier signal.An empty MUST tier for a file with no tests and no spec mentions is not evidence that no requirement applies. The note stops the agent from reading silence as coverage. |
|
SR-12-008 |
MUST | spec-backstop MUST exit with code 2 and write an error to stderr when it runs outside a git repository in diff mode, when the --base ref cannot be resolved, or when the manifest file cannot be read.Exit code 1 means unresolved groups. A setup failure must not look like either a pass or a finding. |
|
SR-12-009 |
MUST | spec-backstop MUST move to the INFO tier the symbol-import hits of a changed symbol that more than --hub-threshold test files import (default HUB_THRESHOLD, 10), MUST keep mirror-path and statement-mention hits in the MUST tier regardless, and MUST print each such hub symbol with its importer count in the report.A hub such as BTBridge is imported by most behaviour tests, so symbol-import matching on it puts about 75 groups in MUST and the check stops discriminating. Naming the hubs keeps the demotion visible, so an agent does not read the shorter list as coverage. |
Refines: SR-12-003 |
SR-12-010 |
MUST | spec-backstop MUST tier a group by the priority of the requirements that matched: a group whose matched requirements are all SHOULD, SHOULD_NOT, or MAY MUST go to the INFO tier, so that only a MUST or MUST_NOT requirement can leave the manifest unresolved.An unresolved MUST group blocks the build, so the blocking set has to be requirements that themselves bind. A SHOULD-only group such as TRIG-05 forced a spec load with no obligation behind it. |
Refines: SR-12-004 |
SR-12-011 |
MUST | spec-backstop MUST move to the INFO tier the marker-derived hits of a test file whose spec markers span more than MONOLITH_GROUP_SPAN requirement groups, MUST exclude module-level boilerplate names (logger, log, and case variants) from the changed-symbol set, and MUST report when a diff contains no Python source that a passing exit status is not coverage.Three distinct false-positive sources measured on real diffs: a 17-group catch-all suite put 13 unrelated groups in MUST from one import; logger is assigned in nearly every module, so it matched every requirement mentioning logger.info; and a docs-only diff exits 0 with no signal at all, which reads as a pass. The median test file here spans 2 groups and the 90th percentile spans 6, so a threshold of 8 demotes only genuine whole-subsystem suites. |
Refines: SR-12-003 |
SR-12-012 |
MUST | spec-backstop MUST pin the diff prefixes it parses by passing --src-prefix=a/ and --dst-prefix=b/ to git diff, MUST accept a --paths entry that does not exist yet, and MUST report any changed path it did not analyse because the path falls outside vultron/ and test/.Each of the three turns a real condition into a silent pass. diff.mnemonicPrefix renames the prefixes and every file then parses as unchanged: measured, 6 changed files and 4 MUST groups became 0 and 0, exit 0 against any manifest. --paths names files the work will create, so a missing file cannot be a setup error. A path outside both trees was dropped without a word. |
Refines: SR-12-001 |
SR-12-013 |
MUST | When spec-backstop reads a manifest entry, a free-text reason introduced by an em-dash MUST run to the next ; or to a sentence-ending . that a fresh ID begins, and a , inside that reason MUST NOT start a new entry.Splitting a reason on every comma read prose as a dismissal: BT-01 — irrelevant, CM-02 covers it marked CM-02 dismissed, so an unresolved MUST group for CM-02 passed the gate. The asymmetry decides the rule — over-reading a reason costs a false alarm the author can answer, while under-reading it silently drops the check. |
Refines: SR-12-006 |
Relationship Type Semantics and Direction
What each rel_type asserts, which end of a relationship is upstream, which end records it, and the spec-lint checks that keep the requirements graph free of loops so that "what does a change to this requirement affect?" has one answer.
| ID | Priority | Requirement | Related |
|---|---|---|---|
SR-13-014 |
MUST | spec-lint MUST report as a hard error each loop in the requirements graph whose arcs run from each relationship's downstream end to its upstream end, that is each strongly connected component of more than one requirement and each requirement whose relationships name itself, and the error MUST name every requirement in the loop. "What does a change to this requirement affect?" walks the graph against its arrows, and a loop makes every member upstream of every other. Orienting by upstream end, not by which end recorded the link, also catches a constrains recorded backwards, which the recorded edges alone hide. |
Depends on: SR-13-012 Refines: SR-04-002 |
SR-13-015 |
MUST | spec-lint MUST report as a hard error each pair of requirements whose relationships name each other, naming both requirements and every relationship between them. A pair recorded at both ends is not always a loop: A refines B with B constrains A points both arcs the same way. The pair check enforces SR-13-012 directly. |
Depends on: SR-13-012 Refines: SR-04-002 |
SR-13-016 |
MUST | The RelationType docstring MUST list each member's definition from one table in vultron/metadata/specs/schema.py that also records each member's upstream end, and a test MUST fail when that table and SR-13-001 through SR-13-011 differ.The definitions are needed both by readers of the specs and by the code that orients the graph. One table checked against the requirements keeps the two from drifting, where a second prose copy would not. |
Depends on: SR-02-004 |
State Machine
Requirements governing how the RM (Report Management), EM (Embargo Management), and CS (CVD Case State: VFD and PXA axes) protocol state machines are defined, represented, and executed at runtime. Each participant maintains its own RM state; the CASE_MANAGER maintains shared EM and Case State. Within CS, the PXA axis is shared across participants while the VFD axis is per-participant.
File ID: SM
State Enum Design
State Machine Definitions
| ID | Priority | Requirement | Related |
|---|---|---|---|
SM-02-002 |
MUST NOT | create_*_machine() factory functions MUST NOT be called for runtime state changes in production use-case or handler code |
Extends: SM-02-001 |
SM-02-003 |
SHOULD | Trigger names for transitions library machines SHOULD be defined as a companion StrEnum (e.g., RM_Trigger) using auto() so that trigger strings remain stable and are not inlined as string literals scattered across the codebase |
Transition Definitions
| ID | Priority | Requirement | Related |
|---|---|---|---|
SM-03-003 |
MUST | Every valid (source → dest) pair that the protocol describes MUST have a corresponding transition entry. |
State History
| ID | Priority | Requirement | Related |
|---|---|---|---|
SM-05-003 |
MUST | Status history entries MUST record a trusted server-side timestamp (received_at or updated) set via now_utc() at write time |
Implements: CM-02-009 — case-management.md |
State Subsets and Guards
Wire and DataLayer Compatibility
CS Forced Transitions and Reachable States
| ID | Priority | Requirement | Related |
|---|---|---|---|
SM-09-001 |
MUST | The CS state machine MUST enforce two forced (ephemeral) transitions at validation time: (1) pX → PX — exploit-public forces public-aware; (2) vP → VP — public-aware forces vendor-aware. Any incoming CS state that contains pX or vP MUST be promoted to the forced form before being persisted.These transitions are protocol-correctness invariants, not optional normalizations. A CS state of pXa (exploit public, public unaware) is semantically impossible — the exploit being public implies the vulnerability is public. Implementors MUST use is_ephemeral_cs_state() and required_next_cs_events() from vultron/core/states/cs_invariants.py to enforce this rule at the persistence boundary. This requirement governs the persistence boundary only; CSB-17-003 and CSB-17-005 cover trajectory validation, where an ephemeral state is a legal prefix terminus. |
Implements: VP-14-001 — exploit/attack public implies embargo termination, not just awareness |
Status Dimension Objects
Normative requirements for per-machine dimension objects decomposed from CaseStatus and ParticipantStatus. Each independent state machine (EM, PXA, RM, VF, D) is represented by a lightweight Pydantic BaseModel that holds its state enum and owns immutable transition() validation and guard methods.
File ID: SDO
Dimension Object Base Contract
| ID | Priority | Requirement | Related |
|---|---|---|---|
SDO-01-001 |
MUST | Each status dimension (EM, PXA, RM, VF, D) MUST be implemented as a lightweight Pydantic BaseModel subclass (not a CoreObject subclass) that holds exactly one state enum field and, except EM, owns the transition validation and guard methods for that machine. EM has no transitions of its own, and EmDimension holds the copy of the EM state that the case derives from its embargo register (ADR-0122).Dimension objects are embedded value objects; they have no independent identity and do not require CORE_VOCABULARY registration or AS2 id_. See ADR-0036. |
|
SDO-01-002 |
MUST NOT | Dimension objects MUST NOT be registered in CORE_VOCABULARY or given an id_ field. They are always embedded in CaseStatus or ParticipantStatus, which already carries identity. Independent registration would create unnecessary schema complexity. See ADR-0036. |
|
SDO-01-003 |
MUST | The dimension objects MUST be named: EmDimension (EM state), PxaDimension (CS_pxa state), RmDimension (RM state), VfDimension (CS_vf state, the vendor path) and DDimension (CS_d state, the deployer path). Participant embargo consent is not a status dimension: it is a per-embargo row set on the participant (CM-18-001, ADR-0122). The "Dimension" suffix avoids collision with the existing VfState, DState and PxaState NamedTuples in vultron/core/states/cs.py. ADR-0075 split the former vendor-fix-deployed machine into the VF and D machines, so each has its own dimension object. See ADR-0036. |
|
SDO-01-004 |
MUST | A dimension object MUST serialize to its bare state value, not to a one-key {"state": ...} mapping, and MUST accept both forms on input. The enclosing model carries the wire spelling as a field alias — rm with serialization_alias="rmState" — so the serialized result is {"rmState": "START"}.A dimension holds one data field, state; the rest is behaviour, and behaviour does not serialize. The mapping form would put a container on the wire whose only content is the thing it contains. Accepting both forms keeps callers that construct {"state": ...} working and rows stored either way readable. This is what lets the core class emit the payload the wire class emitted, so the wire form is unchanged. |
Refines: SDO-01-001 |
transition() Contract
| ID | Priority | Requirement | Related |
|---|---|---|---|
SDO-02-003 |
MUST NOT | transition() MUST NOT mutate self. The caller MUST use the returned dimension object as the updated state. Mutation of embedded Pydantic models is fragile and inconsistent with the append-only history model. See ADR-0036. |
|
SDO-02-005 |
MUST | The SDO-02-004 transition-rejection invariant MUST be enforced via Pydantic field or model validators so that validation occurs at model construction time, not via caller-side pre-checks. | Implements: SDO-02-004 |
CaseStatus and ParticipantStatus Field Shape
| ID | Priority | Requirement | Related |
|---|---|---|---|
SDO-03-003 |
MUST NOT | The old flat enum fields (em_state, pxa_state, rm_state, vfd_state, em_consent_state) MUST NOT be retained as aliases or shim properties after the migration is complete. Keeping both representations creates an extended half-migrated state and perpetuates the flat-enum problem. See ADR-0036. |
|
SDO-03-004 |
MUST | CaseStatus and ParticipantStatus MUST remain round-trippable through the DataLayer after the dimension-object migration: a persisted record MUST rehydrate correctly to the new dimension-object shape. DataLayer round-trip compatibility is a binding design constraint per LST-05-003 and ADR-0033. A dimension field serializes as its bare state value and rehydrates via Pydantic model_validate (SDO-01-004, ADR-0099). It previously serialized as a nested one-key mapping; both forms are accepted on input so stored rows written either way rehydrate. See ADR-0036. |
Depends on: LST-05-003 |
SDO-03-005 |
MUST | Because unknown keys are ignored by default in Pydantic v2, removing a flat-field migration shim from CaseStatus or ParticipantStatus MUST NOT leave a retired flat key (rm_state, vf_state, d_state, vfd_state/vfdState, em_consent_state/emConsentState, em_state, pxa_state) silently discardable. The camelCase rmState, vfState, dState, emState and pxaState are the wire aliases of the dimension fields (SDO-01), not retired keys. The guarantee MUST be the extra="forbid" model configuration that ARCH-12-003 requires of every core-branch type, not a per-class model_validator(mode="before") reject-guard; such a guard MUST NOT be added or retained for this purpose.With extra="ignore", a flat rm_state key would be silently discarded and rm.state would default to RM.START — a whole RM ladder lost with no error, which is the defect class of issue #2232 and a violation of ARCH-15-001/ARCH-15-002. extra="forbid" is strictly stronger than a reject-guard enumerating the known flat keys: it also catches typos and stale keys from an older schema, needs no per-class maintenance, and covers every registered core type rather than the two named here. Because Pydantic itself raises, the failure surfaces as a pydantic ValidationError rather than VultronValidationError, and no wire→core recovery is attempted (ADR-0082 retired the ADR-0062 backstop, and ADR-0099 keeps extra="forbid" as the guard). |
Depends on: SDO-03-003 — SDO-03-003 requires the shims to go; this requirement constrains how they are removed safely. Depends on: ARCH-12-003 — ARCH-12-003 supplies the extra="forbid" configuration that replaces the per-class reject-guard.Refines: ARCH-15-002 |
SDO-03-006 |
MUST | A CaseStatus's EM copy MUST be immutable once the status is constructed. EmDimension MUST be a frozen model and CaseStatus.em a frozen field, so assigning status.em, status.em.state, or any flat EM spelling raises. A status with a different EM is built by construction or by the copy CaseStatus.with_em makes, never by mutation; the owning VulnerabilityCase stamps its current status that way from its embargo register. The copy's label is derived again from its states, so it never names the EM it replaced (#5124).EM is derived from the case's embargo register (ADR-0122); the status holds only a stamped copy. An in-place write makes the copy disagree with the register, and EMB-18-001 forbade it with no enforcement. Freezing fails every write path at runtime, including dynamic ones a source ratchet would miss, while the case's copy-based stamp still works. EmDimension has no transitions, so freezing removes no behavior (#4321). |
Refines: EMB-18-001 — EMB-18-001 forbids assigning case.current_status.em; this requirement makes the assignment impossible.Depends on: SDO-03-001 |
Wire Projection and Migration Scope
Structured Logging Requirements
Logging requirements for the Vultron API, covering log format, correlation IDs, log levels, state transition logging, error context, and authorization and data-access audit records.
File ID: SL
Log Format
| ID | Priority | Requirement | Related |
|---|---|---|---|
SL-01-001 |
MUST | All log entries MUST include timestamp field in ISO 8601 format |
|
SL-01-002 |
MUST | All log entries MUST include level field (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
|
SL-01-003 |
MUST | All log entries MUST include component field (module or function name) |
|
SL-01-004 |
MUST | All log entries MUST include message field (human-readable text) |
|
SL-01-005 |
MUST | The message argument of a log call MUST be a literal template whose values are passed as lazy positional arguments (logger.info("Actor %s engaged case %s", actor_id, case_id)), never a string formatted before the call by an f-string, str.format(), %-formatting, or concatenation.Python's logging keeps the template (msg) and its values (args) apart on the record and formats them only when a handler emits. That split gives every recurring event one stable template to group, grep, and assert on; it defers rendering the arguments (str() and % conversion) until a record is known to be emitted; and it keeps a fault raised while rendering an argument inside the logging machinery instead of in the caller. Only rendering is deferred: an argument expression is still evaluated at the call, so a value produced by a call (a tree dump, a logfmt() render) still needs an isEnabledFor guard, while a guard that only avoided f-string formatting is removed. A pre-formatted string discards all three properties. Structured record fields (SL-02) are a separate mechanism and are not an alternative way to interpolate values into the message. Enforced by ruff rules G001-G004 (ADR-0094). |
Refines: SL-01-004 |
Correlation IDs
| ID | Priority | Requirement | Related |
|---|---|---|---|
SL-02-001 |
MUST | Log entries MUST include activity_id field when activity is available |
|
SL-02-002 |
MUST | Log entries MUST include actor_id field when actor is available |
|
SL-02-003 |
MUST | A correlation field (activity_id, actor_id) MUST reach the log record as a record attribute populated from ambient context that is established once at the processing boundary (inbox receipt of an activity, BT execution on behalf of an actor) and applied to every record by a logging.Filter, not supplied per call through extra=.A per-call extra= at every log site repeats the same value hundreds of times and is missed wherever a site forgets it, which is the duplication failure the template rule (SL-01-005) exists to avoid on the message side. Context set once at the boundary and copied by a filter reaches every record emitted inside that boundary, including records from code that knows nothing about correlation. Rendering stays the formatter's decision: a text formatter can print the attributes beside the message and a structured formatter can emit them as fields, without touching any call site. |
Refines: SL-02-001 Refines: SL-02-002 |
SL-02-004 |
MUST | Interpolating an identifier into the message text MUST NOT be treated as satisfying SL-02-001 or SL-02-002; the message may name the identifier for readability, but the record attribute is the carrier those requirements refer to. An identifier embedded in free text cannot be filtered, grouped, or asserted on without parsing the message, and the narrative INFO template (SL-04-006) names the actor for the human reader, not for correlation. Making the record attribute the only thing that counts keeps the two audiences from being conflated: the message serves the person reading the transcript, the attribute serves the tooling. |
Refines: SL-02-001 Refines: SL-02-002 |
Log Level Semantics
State Transition Logging
Error Context
Authorization Logging
Data Access Logging
Performance Metrics
| ID | Priority | Requirement | Related |
|---|---|---|---|
SL-08-001 |
MAY | System MAY log performance metrics at DEBUG level |
Sync Behavior Trees
Requirements for behavior-tree-based implementation of sync log entry protocol flows. Covers three BTs: AnnounceLogEntryReceivedBT (received-side hash-chain validation with case-actor vs non-case-actor branching), RejectLogEntryReceivedBT (replication state update and replay), and CommitLogEntryBT (append, persist, fan-out).
File ID: SBT
General Sync BT Requirements
| ID | Priority | Requirement | Related |
|---|---|---|---|
SBT-01-001 |
MUST | All sync log entry protocol flows MUST be implemented as behavior trees, not procedural use-case code. Procedural sync logic in received/sync.py and triggers/sync.py MUST be replaced by BT invocations via BTBridge. |
Implements: BT-06-001 — Protocol-significant behavior in BTs Implements: BT-06-006 — Protocol actions MUST NOT be outside BT |
SBT-01-002 |
MUST | Sync BTs MUST be organized as three separate trees, each corresponding to a single message-type use case: (1) AnnounceLogEntryReceivedBT, (2) RejectLogEntryReceivedBT, (3) CommitLogEntryBT. This preserves the one-message-type-per-use-case pattern. | |
SBT-01-003 |
MUST | Sync BT nodes MUST reside in vultron/core/behaviors/sync/ with separate modules for node definitions (nodes.py) and tree composition functions (one module per tree). |
|
SBT-01-004 |
MUST | Driven ports (SyncActivityPort, DataLayer) MUST be injected into BT nodes via the blackboard context set up by BTBridge.setup_tree(). BT nodes MUST NOT construct or acquire ports themselves. | Extends: BT-07-001 — BT nodes use DataLayer via Protocol |
SBT-01-005 |
MUST | Core use cases MUST NOT call trigger functions for sync operations. Triggers exist as external API entry points (demo puppeteering); core work flows through BTs. The CommitLogEntryBT replaces commit_log_entry_trigger() for core-initiated log commits. |
AnnounceLogEntryReceivedBT
| ID | Priority | Requirement | Related |
|---|---|---|---|
SBT-02-001 |
MUST | AnnounceLogEntryReceivedBT MUST include an explicit branch that determines whether the receiving actor is the case-actor for the case referenced in the log entry. The case-actor path and the non-case-actor (participant) path MUST have distinct subtrees. | |
SBT-02-002 |
MUST | The non-case-actor (participant) path MUST validate the received log entry's hash chain before persisting. | Implements: SYNC-03-001 — Reject on hash-chain divergence |
SBT-02-007 |
MUST | In the non-case-actor path, prev_log_hash MUST match the receiver's local chain tail hash.The hash-chain linkage is the integrity guarantee; accepting an entry whose prev_log_hash does not match the local tail would introduce a fork in the replica's chain. |
|
SBT-02-008 |
MUST | On hash-chain mismatch in the non-case-actor path, the participant MUST send Reject(CaseLedgerEntry) back to the case-actor with its last-accepted hash.The Reject triggers the case-actor's replay path (SYNC-03-002), which resends all entries from the last accepted position forward so the participant can converge. |
|
SBT-02-003 |
MUST | The non-case-actor path MUST check for duplicate entries before persisting (idempotency). If the entry is already stored, the BT MUST return SUCCESS without re-persisting. | Implements: SYNC-03-003 — Idempotent delivery |
SBT-02-004 |
MUST | The case-actor path MUST verify that the log entry's sender matches the case-actor's own identity (actor-ID comparison). | |
SBT-02-009 |
MUST | On sender-identity mismatch (potential injection attempt) in the case-actor path, the BT MUST log at WARNING level including the activity_id and the mismatched actor IDs. A WARNING-level log with the mismatched IDs provides an auditable record of potential injection attempts without exposing internal state to the sender. |
|
SBT-02-010 |
MUST | On sender-identity mismatch in the case-actor path, the BT MUST return FAILURE without persisting. Persisting a log entry whose sender does not match the case-actor's own identity would allow injection of forged entries into the canonical ledger. |
|
SBT-02-005 |
SHOULD | The case-actor path's sender verification SHOULD be designed as a replaceable condition node so that cryptographic signature verification can replace actor-ID comparison in a future phase without restructuring the tree. | |
SBT-02-006 |
MUST | When the case-actor receives a valid round-trip of its own log entry, the BT MUST log at DEBUG level (delivery round-trip confirmation) and return SUCCESS. No state update or re-persistence is performed. Delivery confirmation tracking is deferred to a future phase. |
RejectLogEntryReceivedBT
| ID | Priority | Requirement | Related |
|---|---|---|---|
SBT-03-001 |
MUST | RejectLogEntryReceivedBT MUST update the VultronReplicationState for the rejecting peer with the last-accepted hash reported in the rejection. | Implements: SYNC-04-001 — Per-peer replication state tracking |
SBT-03-002 |
MUST | After updating replication state, RejectLogEntryReceivedBT MUST replay all missing entries from the peer's last-accepted hash forward by queuing Announce(CaseLedgerEntry) activities via SyncActivityPort. |
Implements: SYNC-03-002 — Retry from last-accepted hash |
SBT-03-003 |
MUST | RejectLogEntryReceivedBT MUST resolve the case-actor for the referenced case and use the case-actor's identity as the sender for replayed Announce activities. |
CommitLogEntryBT
| ID | Priority | Requirement | Related |
|---|---|---|---|
SBT-04-001 |
MUST | CommitLogEntryBT MUST compose as a Sequence of discrete BT nodes: (1) reconstruct chain tail hash, (2) create CaseLedgerEntry with computed entry_hash, (3) persist entry to DataLayer, (4) fan out Announce(CaseLedgerEntry) to case participants via SyncActivityPort. | Implements: SYNC-02-002 — Replication transport with sender and hash identification Implements: BT-06-005 — Cascades as BT subtrees, not procedural calls |
SBT-04-002 |
MUST | Fan-out MUST be a separate BT action node (not inlined into the persist step) so that the commit and replication concerns are independently testable and composable. | |
SBT-04-003 |
MUST | The existing CommitCaseLedgerEntryNode in vultron/core/behaviors/case/nodes/lifecycle.py MUST be refactored to invoke CommitLogEntryBT as a subtree rather than calling commit_log_entry_trigger() procedurally. |
|
SBT-04-004 |
MUST | Fan-out MUST only occur after the log entry is durably persisted (committed). An emit node whose activity the ledger records MUST append the activity to the outbox before it runs the commit tree that records it, and MUST NOT run that commit first. The fan-out is the record's delivery, so it follows the record; the activity the record describes is the act, so it precedes it (ADR-0119). |
Implements: SYNC-09-002 — Send-then-commit order (ADR-0119) |
Port Injection via Blackboard
Case Event Log Synchronization
Requirements for the append-only canonical recorded case ledger, replication transport, conflict handling, per-peer state tracking, and retry semantics for distributed case state synchronization across Participant Actors.
File ID: SYNC
Replication Transport
| ID | Priority | Requirement | Related |
|---|---|---|---|
SYNC-02-005 |
MUST | This codebase MUST embed the full inline CaseLedgerEntry in the object of each Announce(CaseLedgerEntry) and MUST NOT use a URI-only reference there.How this codebase meets SYNC-02-004; split from it so SYNC-02-004 states only what other participants observe (MS-12-010). Inlining lets the recipient validate the hash chain without a round-trip to the sender, and this codebase does not yet dereference (AKM-05-003). An entry fetched by reference validates the same way, so carrying it inline is a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: SYNC-02-004 |
Conflict Handling
| ID | Priority | Requirement | Related |
|---|---|---|---|
SYNC-03-010 |
MUST | When this codebase's CASE_MANAGER receives the SYNC-03-007 fault from an active participant of the case (CM-10-004), it MUST log the fault at ERROR, MUST recompute the hash of its own stored copy of the named entry as SYNC-03-006 does, and MUST log whether that copy is intact, or that it holds no copy of the named entry. The fault's purpose is to make the problem visible where the ledger is kept, and the self-check says which side is broken (SYNC-03-008). |
Implements: SYNC-03-008 |
Per-Peer Replication State
| ID | Priority | Requirement | Related |
|---|---|---|---|
SYNC-04-002 |
MUST | Per-peer replication state MUST be persisted via the DataLayer so that it survives a leader restart |
Retry and Backoff
| ID | Priority | Requirement | Related |
|---|---|---|---|
SYNC-05-002 |
SHOULD | Retry and backoff parameters SHOULD be configurable | |
SYNC-05-003 |
MUST | Default values for retry and backoff parameters MUST be documented | |
SYNC-05-004 |
MUST | The per-recipient HTTP POST timeout MUST be a configurable parameter of the delivery adapter with a documented default value. The default MUST be set high enough to tolerate a recipient that is briefly busy under normal protocol load (e.g. draining its own outbox); a value of 5 seconds or less is insufficient under CI-representative multi-actor load. The original 5s hardcoded timeout was already rejected as too aggressive by the demo DataLayerClient (timeout=30s, with comment explaining exactly this scenario). A hardcoded timeout that is known to be wrong is a configurable parameter waiting to be written. See CONCERN-2302 §2 and SYNC-05-002. The timeout is an operating limit of this codebase's delivery adapter, which other implementations may set differently, so it is project (ADR-0134). |
Refines: SYNC-05-002 |
Leadership and Ownership
Commit Discipline
Testing
Participant-Local Pending Assertion Suppression
Ledger Write Ownership and Layer Boundary
SYNC-12's already-stored gate (SYNC-12-003) uses the presence of a CaseLedgerEntry in an actor's DataLayer as the sole evidence that the entry's effects were already applied. That inference is only sound if the ONLY thing that ever writes a CaseLedgerEntry to the DataLayer is the core write path that also applies the effects. If any other layer (e.g. a wire/adapter parse or rehydration helper) creates a CaseLedgerEntry record, the gate fires SUCCESS on first delivery and the effects are silently skipped. These requirements make the write-ownership boundary that SYNC-12 depends on explicit.
| ID | Priority | Requirement | Related |
|---|---|---|---|
SYNC-13-001 |
MUST | A CaseLedgerEntry record is written to an actor's DataLayer ONLY by that actor's core ledger write path — the CASE_MANAGER's authoritative append (SYNC-01-004) for its own case, or a participant's PersistReceivedLogEntry step (SYNC-12-001) for a replica. Presence of a CaseLedgerEntry in the DataLayer is defined to mean "this actor's core has committed the entry and applied its effects." | Depends on: SYNC-12-002 — SYNC-12-002 relies on this write-ownership guarantee to hold |
SYNC-13-002 |
MUST NOT | Adapter, wire, transport, and message-parsing code MUST NOT create, save, or otherwise persist a CaseLedgerEntry to the DataLayer.Ingress code delivers the message; it does not write the ledger. Any ingress write would bypass the write-ownership guarantee that SYNC-12 depends on and silently skip effects on first delivery. |
|
SYNC-13-003 |
MUST NOT | Message ingress MUST NOT use the canonical ledger/DataLayer store as a scratch or lookup cache for routing, semantic classification, or rehydration of an inbound Announce(CaseLedgerEntry). The inline CaseLedgerEntry parsed from the wire envelope MUST be carried forward in-memory (or via a dedicated, non-ledger holding area) so that semantic routing and effect application do not depend on pre-storing the entry. | |
SYNC-13-007 |
MUST | This codebase MUST resolve an inbound Announce(CaseLedgerEntry) to the log-entry semantic (ANNOUNCE_CASE_LEDGER_ENTRY) on first delivery using the typed inline object obtained during parsing, without a DataLayer round-trip. How this codebase meets SYNC-13-004; split from it so SYNC-13-004 states only what other participants observe (MS-12-010). Classifying from the inline object relies on this codebase carrying the entry inline (SYNC-02-005), a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: SYNC-13-004 |
Out-of-Order Entry Buffering and Drain
Announce(CaseLedgerEntry) is delivered over a transport with no ordering guarantee, and Vultron may not be the only implementation on the wire, so a replica can receive an entry before its hash-chain predecessor. Dropping such an entry and relying on a reject/replay round-trip does not converge under adversarial reordering, because replay re-announces entries individually over the same unordered transport (they can reorder and be dropped again). These requirements make replica convergence independent of delivery order by holding the received Announce of a forward-gap entry in a non-ledger holding area and replaying it, in hash-chain order, once the entry that closes the gap is committed. What is held and replayed is the Announce that was received, never one the replica builds. Rationale: ADR-0037 (docs/adr/0037-buffer-out-of-order-ledger-entries.md), ADR-0135.
| ID | Priority | Requirement | Related |
|---|---|---|---|
SYNC-14-009 |
MUST | When a held Announce(CaseLedgerEntry) becomes appliable, either because a participant committed its entry's predecessor or because the participant seeded the case (SYNC-15-005), the drain MUST replay that held Announce through the receiving actor's own inbox processing as a delivery of its own, through the inbox's sole entry point (IO-02-003) and under the receiving actor, rather than running its tree inside the delivery that made it appliable. The replay MUST be the Announce that was received, parsed again from the body held for it, so it carries the first delivery's received evidence (VM-08-002); the participant MUST NOT build an Announce of its own, or attribute one to the CASE_MANAGER or to any sender that did not send it. The replayed Announce's own tree replays the next one, so the cascade (SYNC-14-003) needs no loop in the use case. Running the successor inline made one delivery run several trees, chosen by Python (CLP-10-005), and attributed each drained entry to a sender it synthesized (the CASE_MANAGER when known, else the case id). Folding the drain into the tree would nest one bridge run inside another, which hides an internal error in the inner run. A delivery of its own makes the drain the same path as in-order delivery by construction (SYNC-14-008) (ISSUE-4585, ADR-0135). An Announce the replica built and attributed to the CASE_MANAGER was never sent by anyone, yet intake archived it as received evidence: a receiver that did not receive an Announce cannot make one up (ISSUE-5012). |
Refines: SYNC-14-008 Refines: SYNC-15-005 Refines: CLP-10-005 Refines: SYNC-14-001 |
SYNC-14-005 |
MUST NOT | The out-of-order holding area MUST NOT be the canonical ledger: it MAY keep the held Announce in the receiver's DataLayer only as records of its own kind, never as a CaseLedgerEntry, and a held entry MUST NOT be persisted as a CaseLedgerEntry until it is drained (i.e. until its predecessor is present), so that presence of a CaseLedgerEntry in the DataLayer continues to mean "committed and effects applied" (SYNC-13-001). | Depends on: SYNC-13-001 — Holding an entry as a CaseLedgerEntry would break the presence-means-committed invariant |
SYNC-14-006 |
SHOULD | The holding area SHOULD be bounded in size per case; when full it SHOULD evict the held Announce whose entry is farthest ahead of the current gap (highest log_index), body and all, and log a warning, or decline to hold the new one when its entry is itself the farthest ahead. Eviction is recoverable because SYNC-14-002 already sent a Reject, so eviction MUST NOT be relied upon to trigger recovery by itself. |
Divergence Recovery
Under the copy model (ADR-0124) a replica applies each entry through one apply function that refuses an entry whose starting state does not match the replica's case file. When the refused entry already extends the replica's stored chain, no replay can fill the gap: the replica's case file has diverged from the CASE_MANAGER's, and the reject and replay path resends the same refused entry forever. A divergence is a defect, not a steady state, so the replica reports it and the CASE_MANAGER repairs it with a fresh case copy that names the ledger position it reflects. The fault is replication signalling, like the gap Reject, and is never ledgered. Rationale: ADR-0142.
| ID | Priority | Requirement | Related |
|---|---|---|---|
SYNC-16-008 |
SHOULD | The CASE_MANAGER SHOULD log at ERROR a SYNC-16-001 fault for an entry at or before the tail of the last re-seed it sent that peer. Such an entry is already reflected in the copy the peer was sent, so a refusal of it means the CASE_MANAGER's own case disagrees with its ledger. That is a defect, not recovery, and must not read as routine. |
Refines: SYNC-16-004 |
Participant Position Statements
A participant's replica can fall behind with nothing to show it: when the last Announce(CaseLedgerEntry) before a quiet period is lost, no later entry arrives with a gap, so no Reject(CaseLedgerEntry) is sent and the reject-and-replay loop never starts. The CASE_MANAGER cannot see the lag either, because nothing tells it how far a participant has got. The participant therefore states its position, when it chooses, in a Read(CaseLedgerEntry), and the CASE_MANAGER answers a position behind its tail by replaying. A position stated on every case activity is planned separately (#5109, #5110) and composes the same answer. This is how the LedgerReconciliation phase detects a gap and retries until convergence (SYNC-00-003, SYNC-00-007, SYNC-00-008). Rationale: ADR-0155.
Vultron Project Technology Stack
This specification defines the normative technology constraints and implementation requirements for the Vultron prototype implementation.
File ID: IMPLTS
Runtime and Language
| ID | Priority | Requirement | Related |
|---|---|---|---|
IMPLTS-01-001 |
MUST | The implementation MUST use Python 3.12 or later. | |
IMPLTS-01-007 |
MUST | Before updating the requires-python floor in pyproject.toml, the full test suite MUST pass on the target Python version in CI, and all static type checks (mypy, pyright) and linters MUST pass under the new runtime |
Refines: IMPLTS-01-001 |
IMPLTS-01-002 |
MUST | The backend API MUST be implemented using FastAPI. | |
IMPLTS-01-003 |
MUST | The backend API MUST expose an OpenAPI specification. | |
IMPLTS-01-004 |
MUST | The system MUST use Pydantic for data validation and data modeling. | |
IMPLTS-01-005 |
MUST | The application MUST use Python’s standard logging framework for structured logging. | |
IMPLTS-01-006 |
MUST | The behavior tree engine MUST be implemented using py_trees. |
Persistence and Data
Interfaces and Tooling
Documentation and Visualization
Containerization and Deployment
Code Quality Tooling
| ID | Priority | Requirement | Related |
|---|---|---|---|
IMPLTS-07-002 |
MUST | The project MUST use pyright for static type checking. | Implements: CS-11-001 |
IMPLTS-07-003 |
MUST | The project MUST use mypy for static type checking. | Implements: CS-11-001 |
IMPLTS-07-008 |
MUST | All functions and methods MUST have cyclomatic complexity ≤ 10, the generally accepted upper bound for maintainable code. Enforced via max-complexity = 10 under [tool.ruff.lint.mccabe] in pyproject.toml, as part of the ruff CI gate and pre-commit hook.The threshold is unchanged; only its enforcement point moved when ADR-0094 retired .flake8. C901 is the ruff rule that reports it, and it is the project's single complexity gate — the competing PLR09xx counters are deliberately excluded so that two differently-calibrated gates cannot disagree. |
|
IMPLTS-07-005 |
MUST | The CI pipeline MUST run tests (pytest), ruff (ruff check and ruff format --check), mypy, and pyright as separate parallel jobs. The build job MUST only execute when all parallel jobs pass.Parallel execution surfaces all failures simultaneously, reducing fix-cycle time and preserving the known-clean codebase baseline. ADR-0094 collapsed the separate Black and flake8 jobs into one ruff job; lint and format remain a single job because ruff performs both in about a second, so splitting them would cost more in runner startup than it saves. |
|
IMPLTS-07-006 |
MUST | The pytest configuration in [tool.pytest.ini_options] MUST include filterwarnings = ["error"] so that test-suite warnings are treated as errors. |
|
IMPLTS-07-009 |
MUST | pyright MUST run in CI on every commit to main and on every pull request targeting main.Static type checking must be enforced on every change to the main branch. |
Refines: IMPLTS-07-002 |
IMPLTS-07-010 |
MUST | pyright MUST pass with zero errors. A zero-error gate ensures the codebase remains fully type-correct under pyright. |
Refines: IMPLTS-07-002 |
IMPLTS-07-011 |
MUST | mypy MUST run in CI on every commit to main and on every pull request targeting main.Static type checking must be enforced on every change to the main branch. |
Refines: IMPLTS-07-003 |
IMPLTS-07-012 |
MUST | mypy MUST pass with zero errors. A zero-error gate ensures the codebase remains fully type-correct under mypy. |
Refines: IMPLTS-07-003 |
IMPLTS-07-015 |
MUST | Existing warnings MUST be resolved before the filterwarnings = ["error"] setting is activated.Unresolved pre-existing warnings must be cleared to avoid a broken CI state when the filterwarnings gate is enabled. |
Refines: IMPLTS-07-006 |
IMPLTS-07-016 |
MUST NOT | New warnings MUST NOT be introduced after filterwarnings = ["error"] is active.Preventing new warnings maintains the zero-warning discipline established by the filterwarnings setting. |
Refines: IMPLTS-07-006 |
IMPLTS-07-017 |
MUST | The project MUST use ruff as its sole Python linter and formatter, configured under [tool.ruff] in pyproject.toml, and ruff MUST be enforced via pre-commit hooks AND in the CI pipeline. Black, flake8 and isort MUST NOT be reintroduced alongside it.One tool covering linting, formatting and import ordering removes the ungated-isort drift trap and reduces the whole-tree gate from tens of seconds to about one, which is the cost paid on every commit that edits a Python file. The prohibition on reintroduction is CS-15-001 applied to tooling: leaving a superseded linter in place as a fallback recreates the duplicate-gate problem ADR-0094 removed. |
Implements: CS-01-001 Implements: CS-02-001 |
IMPLTS-07-018 |
MUST | ruff check and ruff format --check MUST run in CI on every commit to main and on every pull request targeting main, and both MUST pass with zero findings.Carries forward the obligations retired with IMPLTS-07-013 and IMPLTS-07-014. A zero-finding gate is only meaningful because the baseline mechanism in IMPLTS-07-020 keeps known residue visible rather than silently tolerated. |
Refines: IMPLTS-07-017 |
Optional and Prototype Extensions
Testability
The Vultron inbox handler must be thoroughly testable at unit, integration, and system levels. Testability enables confidence in correctness, supports refactoring, and documents expected behavior.
File ID: TB
Testing Framework
| ID | Priority | Requirement | Related |
|---|---|---|---|
TB-01-001 |
MUST | The system MUST use pytest as the testing framework | |
TB-01-002 |
MUST | Test configuration MUST be defined in pyproject.toml |
Test Coverage
| ID | Priority | Requirement | Related |
|---|---|---|---|
TB-02-001 |
MUST | Unit tests MUST achieve 80%+ line coverage | |
TB-02-002 |
MUST | Critical paths MUST achieve 100% line coverage |
Test Organization
Test Data Management
Test Isolation
| ID | Priority | Requirement | Related |
|---|---|---|---|
TB-06-001 |
MUST | Tests MUST be independent and runnable in any order | |
TB-06-002 |
MUST | Tests MUST use test database or mocked database | |
TB-06-003 |
MUST | Test state MUST be reset between tests | |
TB-06-004 |
MUST | Test fixtures MUST clean up created data after test completion Prevents test database bloat and ensures isolation |
|
TB-06-005 |
MUST | Behavior Tree tests MUST clear the py_trees blackboard between tests py_trees blackboard is a global singleton; without clearing, state from one test leaks into subsequent tests |
|
TB-06-006 |
MUST | All tests MUST be deterministic and produce the same result on every run Flaky tests erode confidence in the test suite, mask real failures, and slow development. A flaky test MUST be fixed or removed; it MUST NOT be left in the suite. |
|
TB-06-007 |
MUST | Integration tests that exercise multi-actor message flows MUST give each simulated actor its own isolated DataLayer instance. Tests that share a single DataLayer across multiple actors bypass the outbox→inbox delivery path and test in-memory state sharing instead of protocol communication.The outbox→inbox delivery path is a critical protocol boundary. A shared DataLayer removes it: actor A's outbox write is immediately visible to actor B without going through any delivery mechanism, making the test a unit test of shared state rather than an integration test of the protocol. |
Refines: TB-06-001 |
TB-06-008 |
MUST | BT factories that have probabilistic defaults (e.g., always-succeed or always-fail stubs for nodes backed by configurable policies) MUST expose a module-level deterministic factory constant for use in tests. Tests MUST use the deterministic factory, not the production random-policy factory, to avoid stochastic test failures. A test that calls a production BT factory configured with a probability distribution may pass or fail depending on random outcomes. The deterministic factory ensures the test exercises a specific code path every time. Convention: name the constant _always_succeed_factory or _always_fail_factory at module level. |
Refines: TB-06-006 |
Mocking and Stubbing
Test Documentation
Test Maintainability
Test Infrastructure Consistency
| ID | Priority | Requirement | Related |
|---|---|---|---|
TB-11-001 |
MUST | When a pytest mark is renamed or added, ALL of the following MUST be updated in the same changeset: (a) the markers list in pyproject.toml, (b) any .github/workflows/ YAML files that reference the mark by name (e.g., -m "mark_name" in pytest invocations), and (c) any test files that apply the mark.A mark renamed in test files but not in a workflow YAML causes pytest to select 0 tests matching the old name and exit with code 5 (no tests collected). CI treats a non-zero exit code as a failure, but the failure message ("no tests ran") is easily misread as an environment issue rather than a naming mismatch. Issue #934 observed this after renaming case_log_invariants to case_ledger_invariants across test files while leaving the workflow YAML referencing the old name. |
Refines: TB-07-001 — Mark consistency between source, config, and CI is part of test infrastructure correctness |
BT Test Harness
| ID | Priority | Requirement | Related |
|---|---|---|---|
TB-12-001 |
MUST | BT leaf and composite node tests MUST use the BTTestScenario deep-module test harness. The setup_node_blackboard() helper is an anti-pattern for integration tests of BT nodes: it creates shallow, manually wired blackboard state that diverges from the tree structure the node expects in production.BTTestScenario instantiates the full tree (or a representative subtree) and exercises nodes in their actual execution context, catching structural assumptions (e.g., parent Sequence providing the blackboard namespace) that setup_node_blackboard() tests cannot detect. |
Refines: TB-03-001 |
TB-12-002 |
MUST | Each BT scenario test MUST run the BT tree exactly once per test case. Running the same tree instance multiple times in a single test produces accumulated blackboard state that masks isolation failures. Each test case MUST get a fresh tree instance. | Refines: TB-12-001 |
TB-12-003 |
SHOULD | A BT integration test SHOULD complete within 5 seconds of honest work per test case on an idle machine. This is a design budget that keeps BT scenarios small, not a ceiling. The per-test timeout tiers (TB-14-001) sit well above it so that load on a CI runner or dev box never trips them on honest work, which means no timeout enforces this budget. |
Refines: TB-12-001 |
TB-12-004 |
SHOULD | When a BT test scenario produces a SUBFAILED status (partial-success in a memory=True Sequence), the test SHOULD explicitly assert the expected leaf statuses rather than asserting only the root status. SUBFAILED on the root masks which children succeeded and which failed. |
Refines: TB-12-001 |
TB-12-005 |
SHOULD | A BT test that exceeds the TB-12-003 budget SHOULD be redesigned (e.g., reducing dataset size, mocking external I/O) rather than given its own longer @pytest.mark.timeout.A per-test override hides a slow scenario from the budget it breaks. Redesigning the test keeps the scenario small. A timeout tier that fires on honest work across many tests is a different problem, covered by TB-14-003. |
Refines: TB-12-003 |
Architecture Ratchet Corpus
| ID | Priority | Requirement | Related |
|---|---|---|---|
TB-13-001 |
MUST | All architecture ratchet tests in test/architecture/ that scan the source tree MUST obtain file paths and source text from a shared module-level corpus helper (test/architecture/_corpus.py) rather than calling pathlib.Path.rglob or reading files directly.Scanning 1 179 Python files at module-import time (exempt from the per-test timeout) costs ~1 s for source reads and populates an 18 MB string cache. All subsequent ratchets share those bytes for free. Direct rglob calls inside test functions compete for disk I/O inside the timeout window and accumulate as more ratchets are added. Source: CONCERN-2020. |
|
TB-13-002 |
MUST | Architecture ratchet tests MUST apply a substring prefilter before calling ast.parse().ast.parse() is the dominant per-file cost (~1 ms each). A plain in substring check is ~1 µs. For import-boundary ratchets the prefilter string is the module path being guarded (e.g., "vultron.wire.as2.vocab.activities"); for attribute-name ratchets it is the attribute string (e.g., "ASGITransport"). Measured reduction: test_vocab_activities_boundary 3.8 s → 0.04 s. Source: CONCERN-2020. |
Refines: TB-13-001 |
TB-13-003 |
MUST | No file in test/architecture/ MAY call ast.parse() or pathlib.Path.rglob directly. A meta-ratchet in test/architecture/test_ratchet_hygiene.py MUST fail when any sibling file contains a bare ast.parse( or .rglob( call outside of _corpus.py.The meta-ratchet makes the shared-corpus pattern self-enforcing. Without it, the next ratchet author can inadvertently reintroduce the pattern the corpus was designed to eliminate, and the problem silently recurs with each new ratchet. The hygiene check itself MUST use plain substring scanning on source strings (via _corpus.py) rather than calling ast.parse() directly — otherwise it would violate the rule it enforces. Source: CONCERN-2020. |
Refines: TB-13-001 |
TB-13-004 |
SHOULD | Each architecture ratchet test SHOULD complete in under 0.5 seconds. Ratchets run on every local gate (PAD-18-002) and scan the whole source tree, so their cost is paid on every fix commit. The shared corpus (TB-13-001) and the substring prefilter (TB-13-002) make this budget reachable. Per-test timeouts are a hang detector, not this budget (TB-14-001). Source: CONCERN-2020. |
|
TB-13-005 |
SHOULD | The test suite SHOULD be validated for pytest-xdist (-n auto) compatibility before parallel execution is enabled in CI.pytest-xdist is already a declared dependency. Enabling -n auto on a fully compatible suite can reduce full-suite wall-clock by 50–70% on multi-core runners. Source: CONCERN-2020. |
|
TB-13-006 |
MUST | Tests that rely on shared global state (e.g., py_trees blackboard, module-level singletons) MUST be identified and either isolated or marked xdist-unsafe before pytest-xdist parallel execution is enabled in CI.Unguarded shared state causes non-deterministic failures under xdist. Source: CONCERN-2020. |
Refines: TB-13-005 |
TB-13-007 |
MUST | A file that does not contain the target fragment string as a plain substring MUST be skipped entirely by architecture ratchet tests. Skipping files without a matching substring avoids the ~1 ms per-file ast.parse() cost for files that cannot match the ratchet pattern. |
Refines: TB-13-002 |
TB-13-008 |
MUST NOT | Architecture ratchet tests MUST NOT call ast.parse() on files that do not contain the target fragment string as a plain substring.ast.parse() is the dominant per-file cost (~1 ms each); calling it on non-matching files wastes CI time proportional to corpus size. |
Refines: TB-13-002 |
Test Run Timeouts
| ID | Priority | Requirement | Related |
|---|---|---|---|
TB-14-001 |
MUST | Every test MUST run under a per-test pytest-timeout ceiling configured in pyproject.toml, with a wider tier applied at collection time to every integration-marked test. A test's own @pytest.mark.timeout MUST override the tier.A ceiling turns a hung test into a named failure. Integration tests do more honest work under a wider spread of load, so one ceiling for both is either too tight for one or too loose for the other. The tier depends on the integration marker that directory hooks add, so it is applied after them. |
|
TB-14-002 |
MUST | A per-test timeout trip MUST fail only the test that tripped it. The session MUST go on with the remaining tests, and its summary MUST name every failure. A trip that ends the process loses the names of failures already recorded and never runs the tests after it, so a red run gives no signal. Under pytest-xdist the ended process is a worker, which TB-14-004 must then handle. The signal timeout method meets this; the thread method does not. |
Refines: TB-14-001 |
TB-14-003 |
SHOULD | When a timeout tier fires on honest work rather than on a hang, the tier SHOULD be raised, rather than each affected test given its own @pytest.mark.timeout or recorded as flaky.A ceiling is a hang detector, not a performance budget. Contention on CI runners and loaded dev boxes inflates honest tests far past their idle cost, and a too-tight tier reads as flakiness that is re-diagnosed again and again. |
Refines: TB-14-001 |
TB-14-004 |
MUST | A pytest-xdist run MUST fail, and name the test the worker was running, when a worker process dies. It MUST NOT wait for a replacement worker to finish the dead worker's tests.Under --dist loadgroup xdist re-queues a dead worker's finished work units as well as its unfinished ones, and a replacement worker handed a unit with nothing to run never reports, so the run waits forever. --max-worker-restart=0 in addopts stops the replacement. |
|
TB-14-005 |
MUST | Every local gate command written for agents, and the CI test job, MUST run under a wall-clock ceiling, so that a hang no per-test timeout can reach ends the run with a known exit status. A signal-based per-test timeout cannot interrupt a C call that holds the GIL, and a stuck xdist controller runs no test at all. Without an outer ceiling such a run waits until the agent's tool timeout or GitHub's job default. Agents run in the Linux devcontainer. Human how-to pages under docs/developer/ keep the bare command, because stock macOS has no timeout. |
Specification Traceability
Requirements for maintaining the traceability matrix that maps user stories to specification requirements, including coverage flagging and maintenance obligations.
File ID: TRACE
Traceability Matrix
Maintenance
Triggerable Behaviors
Requirements for the trigger API that allows an actor to initiate protocol behaviors based on its own internal state, rather than reacting to an inbound ActivityStreams message. Triggerable behaviors are the outgoing counterpart to the inbound handler pipeline and are required to demonstrate the full CVD protocol.
File ID: TRIG
Endpoint Format
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-01-001 |
MUST | The system MUST expose trigger endpoints using the path pattern POST /actors/{actor_id}/trigger/{behavior-name} |
|
TRIG-01-002 |
MUST | Trigger endpoints MUST return HTTP 202 on successful acceptance | Depends on: HTTP-01-003 |
TRIG-01-003 |
MUST | Trigger endpoints MUST return a structured error response when the actor, case, or report referenced in the request body cannot be resolved | Depends on: EH-05-001 |
TRIG-01-004 |
MUST NOT | Trigger endpoint processing MUST NOT block the HTTP response; long-running behavior execution MUST run as a background task | Depends on: IE-03-001 |
Candidate RM Behaviors
Request Body
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-03-001 |
MUST | Each trigger request body MUST include at least one of case_id (for case-scoped behaviors) or offer_id (for offer-response behaviors). Full per-behavior field requirements are defined in the API schema. |
|
TRIG-03-002 |
MUST | Unknown fields in the request body MUST be ignored (forward-compatibility) | |
TRIG-03-003 |
SHOULD | The trigger endpoint request body SHOULD support an optional note field containing free-text content that will be embedded in the outgoing ActivityStreams activity (e.g., rationale for embargo proposal, reason for deferral) |
|
TRIG-03-004 |
MUST | The reject-report trigger request body MUST include a note field (reason required); the note value SHOULD be non-emptyHard-close decisions are irreversible and warrant documented justification for audit purposes |
Refines: TRIG-03-003 Depends on: CS-08-001 |
Response Body
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-04-001 |
SHOULD | A successful trigger response SHOULD include the resulting ActivityStreams activity in the response body under an activity key: |
|
TRIG-04-002 |
MAY | When a trigger initiates a long-running behavior, the response MAY return a job object per AR-04-001 instead of the activity directly |
Depends on: AR-04-001 |
TRIG-04-003 |
MUST | When a triggered behavior terminates because it emitted an authorization request rather than completing the requested action, the trigger response MUST identify the request it emitted. A trigger returns 202 and runs the behavior in the background, so a caller cannot otherwise distinguish "done" from "waiting on someone else's decision" (CONCERN-2369). Under ASK-01-002 success means the request was emitted, so success alone is ambiguous; naming the request lets the caller resolve the outcome against the outstanding-ask collection (ASK-04-009). |
Depends on: ASK-04-009 |
BT Integration
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-05-002 |
SHOULD | The trigger endpoint SHOULD invoke the BT tree via the bridge layer (vultron/core/behaviors/bridge.py) using the same pattern as existing BT-using handlers |
Depends on: BT-05-001 |
Per-Actor DataLayer
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-06-001 |
MUST | Trigger endpoints MUST resolve the correct per-actor DataLayer instance using the actor_id path parameterTrigger endpoints are this codebase's local REST surface for driving its own actors, not messages between implementations, and per-actor stores are how this codebase isolates them (ADR-0073). Only this codebase must obey it, so it is project (ADR-0134). |
Depends on: CM-01-001 |
TRIG-06-002 |
MUST | Trigger endpoint implementations MUST accept the DataLayer instance via dependency injection to allow per-actor isolation to be retrofitted without changing endpoint contracts | Depends on: CM-01-001 |
Trigger Classification
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-08-001 |
MUST | The application MUST define a RunMode StrEnum with at least two values: PROTOTYPE and PROD |
|
TRIG-08-002 |
MUST | A trigger is general-purpose if it represents either a legitimate external stimulus (e.g., a finder submitting a report) or an intentional actor decision that a human operator or agentic client might initiate (e.g., validating a report, proposing an embargo). A trigger is demo-only if the only reason it exists is to let a demo script puppeteer an actor through a step the actor's own BT would handle autonomously in a real deployment. | |
TRIG-08-003 |
MUST | General-purpose triggers MUST be exposed under the path prefix /actors/{actor_id}/trigger/{behavior-name} (TRIG-01-001) |
|
TRIG-08-004 |
MUST | Demo-only triggers MUST be exposed under the path prefix /actors/{actor_id}/demo/{behavior-name} (see TRIG-09-001) |
|
TRIG-08-005 |
MUST | RunMode MUST be stored as ServerConfig.run_mode within AppConfig (CFG-04-001) with a default of RunMode.PROTOTYPE |
Depends on: CFG-04-001 |
Demo Trigger Endpoints
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-09-001 |
MUST | Demo-only trigger endpoints MUST use the URL path pattern POST /actors/{actor_id}/demo/{behavior-name} |
|
TRIG-09-002 |
MUST | The demo router MUST be conditionally mounted: it MUST be included in the FastAPI application only when get_config().server.run_mode == RunMode.PROTOTYPE |
|
TRIG-09-003 |
MUST | When run_mode == RunMode.PROD, any request to a path under /demo/ MUST return HTTP 404 (the routes are not mounted; no custom error message is required) |
Depends on: TRIG-09-002 |
TRIG-09-004 |
MUST | Demo trigger endpoints MUST share the same per-actor DataLayer injection pattern as general-purpose trigger endpoints (TRIG-06-001, TRIG-06-002) | |
TRIG-09-005 |
MUST | Demo trigger endpoints MUST use an OpenAPI tag that visually distinguishes them from general-purpose triggers in the Swagger UI (e.g., tags=["Demo Triggers"] vs tags=["Triggers"]) |
Generalized Object Triggers and Type-Specific Wrappers
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-10-002 |
MUST | Type-specific convenience triggers (e.g., add-report-to-case) MUST delegate to the corresponding general trigger after performing type-specific validation |
|
TRIG-10-003 |
MUST NOT | add-note-to-case MUST NOT be exposed as a general /trigger/ endpoint; it MUST be moved to /demo/add-note-to-case as a demo-only convenience wrapper around add-object-to-case |
Refines: TRIG-08-004 |
TRIG-10-004 |
MUST NOT | sync-log-entry MUST NOT be exposed as a general /trigger/ endpoint; it MUST be moved to /demo/sync-log-entryIn a correct implementation, SYNC log entries are committed automatically as a cascade effect of every state-changing operation. An explicit trigger exists only to let the demo script inject entries manually. A future production "force-sync" recovery operation would require a protocol-level mechanism for a peer to advertise its current log tail hash before entries are replayed (SYNC-03-004) |
Refines: TRIG-08-004 |
Case Ownership Transfer Triggers
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-11-004 |
MUST | Every ownership-transfer trigger MUST return an activity field in its response body containing the emitted ActivityStreams object, consistent with the response contract for all other trigger endpoints (TRIG-05-001). |
Refines: TRIG-11-001 Refines: TRIG-11-002 Refines: TRIG-11-005 |
Trigger API Contract Stability and Dispatch Registry
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-12-001 |
MUST | Every trigger endpoint (/trigger/ and /demo/) MUST declare a response_model naming the TriggerResult subtype its verb returns, so the OpenAPI document describes the real response body of each verb.Trigger routes returned bare dict for the whole prototype period, so OpenAPI documented no response body and five distinct body shapes were invisible to clients and to review. |
|
TRIG-12-002 |
MUST | The response body of every trigger verb MUST be pinned to an exact key set by a test parametrized over the trigger registry (TRIG-12-004), and response_model serialization MUST keep FastAPI's defaults for response_model_exclude_none, _exclude_unset, _exclude_defaults, and _by_alias, so a key whose value is null is still emitted.activity is null whenever the BT captured nothing; excluding None would silently drop a key clients receive today. Pinning the exact set, rather than asserting one key is present, is what turns a body change into a failing test. |
|
TRIG-12-003 |
MUST NOT | The trigger API contract — paths, operation_ids, status codes, summaries, descriptions, and request schemas — MUST NOT change without a deliberate update to a committed golden OpenAPI snapshot. The snapshot MUST be captured before any trigger route is rewritten, so it freezes the shipped contract rather than whatever a rewrite happened to emit.Adding response_model and collapsing the route bodies touches every trigger endpoint at once. A snapshot taken first is the only mechanical evidence the client-visible contract did not move; one taken afterwards proves nothing. |
|
TRIG-12-004 |
MUST | A single verb-keyed trigger registry MUST be the sole source mapping each trigger verb to its request model, use-case class, result type, and exposure (general-purpose or demo-only). Every trigger route MUST correspond to exactly one registry row and every row to exactly one route; every Svc*UseCase class under vultron/core/use_cases/triggers/ MUST appear in exactly one row. The registry holds data and a lookup only; it MUST NOT contain per-verb behavior.The registry exists for enumeration, not routing — a route already knows its verb. Enumeration is what lets TRIG-08-003/004, TRIG-12-001, and TRIG-12-002 be asserted as tests instead of by inspection, and what catches an unrouted use case ( SvcAddOnBehalfStatusUseCase was reachable only from its own tests). Keeping behavior out of it is what keeps it from becoming the per-verb facade it replaces (ADR-0110). |
Refines: TRIG-08-003 Refines: TRIG-08-004 |
Leaving a Case
| ID | Priority | Requirement | Related |
|---|---|---|---|
TRIG-13-001 |
MUST | The system MUST expose a general-purpose leave-case trigger at POST /actors/{actor_id}/trigger/leave-case that accepts a case_id and sends Leave(VulnerabilityCase) from the requesting actor, addressed to the case's CASE_MANAGER. It accepts a call from any participant of the case; what the Leave means for the case is decided by the CASE_MANAGER on receipt (CM-23-002, CM-23-003).A participant that has joined a case closes its part in it by leaving the case (CM-23-001), never by Close(Report), which a case refuses from every sender (CM-23-019, ADR-0144). A production deployment needs a trigger for it: the demo verb was not mounted in production mode (TRIG-09-002). The verb is named for the message, because for a participant that is not the owner the case stays open (ADR-0145). |
Refines: TRIG-02-004 Depends on: CM-23-001 |
TRIG-13-002 |
MUST | The leave-case trigger MUST run the pre-close Actuator call-out (PreCloseAction) for every caller before it sends the Leave. When the call-out fails, the trigger MUST fail with nothing sent.The pre-close step is the leaving actor's own integration work, such as closing its ticket, so it runs where that actor decides to leave. Running it when the actor's replica applies its closure entry would put local policy into replica application, which CM-23-016 forbids (ADR-0145). |
Refines: TRIG-13-001 |
TRIG-13-003 |
MUST | When the actor calling leave-case is the Case Owner and its replica records an active embargo (EM.ACTIVE or EM.REVISE), the trigger MUST fail before the pre-close call-out runs and before anything is sent, with an error that says to terminate the embargo first.The CASE_MANAGER declines the Case Owner's Leave while an embargo is active (CM-23-011). Checking first keeps the owner's own systems from acting on a close that would be declined (ADR-0145). |
Refines: TRIG-13-002 Depends on: CM-23-011 |
Use-Case Organization
Requirements for the physical package layout of the use_cases package, registry synchronization, test mirroring, and documentation of the trigger-to-received-to-sync information flow pattern.
File ID: UCORG
Package Layout
| ID | Priority | Requirement | Related |
|---|---|---|---|
UCORG-01-001 |
MUST | Use-case classes that process inbound received messages MUST reside under vultron/core/use_cases/received/ |
Implements: CS-12-002 — code-style.md |
UCORG-01-002 |
MUST | Trigger use-case classes that implement actor-initiated behaviors MUST reside under vultron/core/use_cases/triggers/ |
Implements: CS-12-002 — code-style.md |
UCORG-01-003 |
MUST NOT | The vultron/core/use_cases/ package root MUST contain only __init__.py and shared private helper modules; use-case implementations MUST NOT be placed at the root levelPreviously required a use_case_map.py at the package root. No such file exists or ever did — the registry lives in vultron/semantic_registry/, where use_case_map() is a function, and DispatcherBase receives the map by injection. The no-implementations-at-root prohibition is the load-bearing half and is retained. See #3377. |
Registry Synchronization
Test Layout
| ID | Priority | Requirement | Related |
|---|---|---|---|
UCORG-03-001 |
MUST | Use-case test modules MUST mirror the structural layout of the use-case source modules they test; each use-case subpackage MUST have a corresponding test subpackage at the same relative path under the test root. | Refines: TB-04-001 — testability.md |
Information Flow Documentation
| ID | Priority | Requirement | Related |
|---|---|---|---|
UCORG-04-001 |
SHOULD | The trigger → received → sync information flow pattern SHOULD be documented in notes/ and summarized in a README.md under vultron/core/use_cases/ |
Use-Case Result Protocol
| ID | Priority | Requirement | Related |
|---|---|---|---|
UCORG-05-001 |
MUST | Every use-case execute() method MUST declare a return type that is UseCaseResult or a subtype (HandlerResult, TriggerResult); bare None or dict return types are not permittedMakes the use-case contract explicit and machine-checkable. See ADR-0040 (docs/adr/0040-use-case-result-envelope.md). |
Implements: CS-11-001 |
UCORG-05-002 |
MUST | Handler (received-side) use cases MUST return HandlerResult.Separate subtypes allow callers to distinguish received-side vs. trigger-side results without isinstance checks on the base. |
|
UCORG-05-003 |
MUST | The UseCase Protocol in vultron/core/ports/use_case.py MUST declare execute() -> UseCaseResult; the return type MUST NOT be Any or None |
|
UCORG-05-004 |
MUST | An architecture ratchet test MUST verify that all concrete use-case classes in vultron/core/use_cases/ declare an execute() return annotation that is UseCaseResult or a registered subtypeThe enforcement for UCORG-05-003. No call site is typed against the UseCase Protocol (the dispatcher routing table holds bare type values), so mypy alone does not report a non-conforming execute(); the ratchet catches it at CI time. |
|
UCORG-05-005 |
MUST | TriggerResult is the fieldless base of the trigger-side result hierarchy. Its ActivityResult subtype MUST carry activity and emitting_actor_id; every other subtype MUST declare exactly the keys its verb's response body carries, and the base MUST reject unknown keys so a use case that grows a return key fails loudly instead of being silently dropped from the body. HandlerResult MUST carry a disposition field of type HandlerDisposition and an optional reason. Either MAY carry further domain-specific result fields but MUST NOT carry raw dict payloads; the one permitted dict value is an emitted activity's JSON object (activity, offer, note), typed dict[NonEmptyString, Any] because its keys are JSON member names (CS-08-001), which core captures from the BT blackboard as JSON and cannot type without a wire import (UCORG-05-014).Four trigger verbs return bodies that carry no emitting_actor_id (and one no activity), so a base that required both could not be satisfied without changing live response bodies. Moving the required fields down to the subtype the BT-backed verbs return lets every verb have an exact, non-optional shape (ADR-0110). |
|
UCORG-05-006 |
MUST NOT | The trigger driving port MUST NOT declare dict or dict[str, Any] as a return type. It exposes one method, trigger(request, dl), whose return type is the TriggerResult subtype bound to the request's type, so a router receives the verb's typed result without a per-verb port method.Propagating dict past the port defeats the typed contract at the router boundary. A per-verb method surface grows linearly with the verbs behind it, flattens each validated request into loose scalars that the next layer reassembles, and hides nothing (ADR-0110). |
|
UCORG-05-007 |
MUST | Trigger use cases MUST return TriggerResult.A dedicated TriggerResult subtype makes trigger-side results structurally distinct from handler-side results without runtime isinstance checks. |
Refines: UCORG-05-002 |
UCORG-05-008 |
MUST | Both HandlerResult and TriggerResult MUST inherit from UseCaseResult.Inheriting from the common base type ensures that all use-case return values satisfy the UseCaseResult Protocol contract. |
Refines: UCORG-05-002 |
UCORG-05-009 |
MUST | HandlerDisposition MUST be a StrEnum whose members are exactly APPLIED, SKIPPED, DEFERRED, and REFUSEDA closed StrEnum follows the project idiom for fixed value sets (CVDRole, VultronObjectType) rather than an inline Literal. DEFERRED is a member because handler-side deferral is real: BufferOutOfOrderEntryNode and BufferPreGenesisEntryNode return SUCCESS after holding the received Announce(CaseLedgerEntry) for replay once its predecessor arrives, which is neither a state change nor a benign no-op nor a refusal. DeferCheckNode decides the other kind of deferral — missing case context — before dispatch; the two are distinct and both map to InboxOutcome.status == "deferred". See ADR-0095. |
|
UCORG-05-010 |
MUST | Every link between a received-side execute() and DispatchNode MUST propagate the returned HandlerResult — specifically DispatcherBase._handle(), DispatcherBase.dispatch(), the ActivityDispatcher Protocol, the adapter-level dispatch() helper, FastAPIDispatchAdapter.dispatch(), and the DispatchAdapter Protocol MUST all declare HandlerResult rather than NoneThe dispatcher boundary is the only route from a handler to InboxOutcome, and no prior ADR reached it; ADR-0095 decides it. Any link left at None silently discards the verdict and reinstates issue #2255. _handle() is named explicitly because it is the link that actually calls execute() — dispatch() only logs and delegates to it — so an implementer working from the public method names alone would leave the first hop at None and lose the verdict before any other link saw it. |
|
UCORG-05-011 |
MUST | InboxOutcome.status MUST be derived from the handler's HandlerDisposition when a dispatch occurred — APPLIED and SKIPPED mapping to processed, DEFERRED mapping to deferred, and REFUSED mapping to rejected with failure_reason populated from the handler's reason — and MUST NOT be inferred from the absence of a raised exceptionTreating "dispatch did not raise" as success is what lets a handler take no action and still be reported as processed (issue #2255). deferred has two producers that must not be conflated: DeferCheckNode before dispatch, for missing case context, and a handler returning DEFERRED after dispatch, for an entry parked pending its predecessor. Both reach the same InboxOutcome.status; only the second flows through this mapping. |
Implements: IO-01-001 |
UCORG-05-012 |
MUST | When a dispatch is attempted but no use case is registered for the event's semantic type, the dispatcher MUST return an explicit REFUSED "no use case found" verdict (SE-05-002) rather than raise or return as though a handler ran: the lookup failure MUST NOT yield InboxOutcome.status == "processed"No handler ran, so the outcome cannot be processed (#2255). The dispatcher names a verdict rather than an outcome, because HP-01-004 reserves InboxOutcome's vocabulary to DispatchNode. It does not raise: a receiver that does not handle a message has not found it invalid, and a raise would answer the sender with a ProcessingFault (ADR-0140 item 5). An activity that names no case is not a no-handler path: routing is by semantics alone, and the handler refuses it (HP-05-002). |
Refines: UCORG-05-011 |
UCORG-05-013 |
MUST | A REFUSED disposition reaching InboxOutcome MUST be logged above DEBUG by the component that observes the completed outcomerun_inbox_pipeline only debug-logs outcome.status, so the verdict is discarded a second time after process_payload returns. Issue #2255's acceptance criterion requires the refusal be visible in the actor log above DEBUG; without this requirement the whole chain could satisfy UCORG-05-010 and UCORG-05-011 and still leave the refusal invisible in normal operation. |
Refines: UCORG-05-011 |
UCORG-05-014 |
MUST | A WireActivityResult model MUST exist in vultron/demo/ that carries a non-optional activity field typed as as_TransitiveActivity. It MUST be constructed by validating the activity key from the raw dict returned by post_to_trigger; it is a typed view of the HTTP response body, not a use-case return value, and MUST NOT be placed in vultron/core/: as_TransitiveActivity is a wire-only class with no core counterpart (ARCH-25-003 aliases the paired domain types, not the activity envelopes), and core MUST NOT import wire (ARCH-01-001). The plain ActivityResult name belongs to the core TriggerResult subtype (UCORG-05-005), whose activity is the JSON object captured from the BT blackboard; a demo-layer typed view MUST NOT reuse a core result class name.Core captures activity as dict[str, Any] from the BT blackboard; a wire type there would violate ARCH-01-001. The demo-layer view validates the raw dict to as_TransitiveActivity at the HTTP boundary so ActorSession is fully typed without polluting core. Two ActivityResult classes in different layers, one JSON and one wire, would be indistinguishable in a traceback, so the wire-typed one takes the Wire prefix WireCaseLedgerEntry already uses (ARCH-04-005). |
Refines: UCORG-05-005 |
Vocabulary Model
Requirements governing how ActivityStreams 2.0 vocabulary types are defined, registered, configured, and deserialized in the Vultron wire layer. The vocabulary model is the foundation of the inbound pipeline: every activity must be deserialized into a known vocabulary type before semantic extraction can assign it a domain meaning.
File ID: VM
Vocabulary Registration
| ID | Priority | Requirement | Related |
|---|---|---|---|
VM-01-001 |
MUST | Every concrete wire-layer AS2 class (object, activity, or link) MUST be registered in the shared VOCABULARY registry automatically at class-definition time |
|
VM-01-002 |
MUST | The VOCABULARY singleton MUST be the sole authoritative source for type lookup during dynamic deserialization |
|
VM-01-003 |
MUST | New vocabulary types MUST be importable at startup without explicit registration calls in application code | |
VM-01-004 |
MUST | The VOCABULARY registry MUST be keyed by the full wire class name (as_VultronPerson), and MUST map each such key to that class. A VOCABULARY key is a class-name lookup and MUST NOT be read as an AS2 type value; resolving an inbound "type": "..." value is WIRE_TYPE_MAP's job (VM-01-008). VOCABULARY keys MUST be disjoint from core vocabulary keys (ARCH-23-002).The two registries answer different questions, so they carry different key forms: VOCABULARY answers "which class is named this?", WIRE_TYPE_MAP answers "which class does this wire type value deserialize to?" (VM-01-008). The class-name form is what keeps these keys disjoint from CORE_VOCABULARY, so no wire class shadows a core type. The as_ prefix is never part of an AS2 type value, so nothing external depends on this key form (ARCH-23-003). |
Depends on: ARCH-23-002 — Disjoint wire and core registry keys are what this requirement's key form delivers. |
VM-01-005 |
MUST | Vocabulary type registration MUST occur automatically at package import time; no explicit per-type registration call MUST be required at call sites. | Refines: VM-01-001 |
VM-01-006 |
MUST | A VocabNamespace enum (values: AS, VULTRON) MUST be defined in vultron/wire/as2/vocab/base/enums.py and used to annotate the provenance of each vocabulary class |
|
VM-01-007 |
MUST | Registration completeness MUST be verified against the wire type a class actually presents, not against the module it lives in. A test MUST assert that every as_Base subclass whose presented type equals its own class-name-derived value is reachable in a wire registry under that value, and MUST hold the exceptions in a declared exemption set that names, per member, the requirement or reason that exempts it. A class's presented type is its type_ field default, or its class name with any as_ prefix removed when that default is None.The pre-#3242 check asked whether each vocabulary module contributed a registration, and skipped any module with no own type_ annotation as a "semantic alias or abstract module". base/objects/collections.py declares no annotation, so it was skipped — and that skip is the defect being tested for. A check whose skip predicate is the defect cannot see the defect. Phrasing the invariant on the presented type value closes that; the exemption set follows ARCH-22-003 so it stays auditable rather than folklore. The presented value is read from the field default rather than an instance because classes with required fields cannot be built without arguments. Abstract roots such as as_Object, as_VultronObject, and as_VultronActorMixin present their own name and are unregistered by design, so they are exemption-set members. |
Verifies: VM-01-001 Depends on: VM-03-002 — The Literal annotation is what makes "distinct wire type" declarable rather than inferred. |
VM-01-008 |
MUST | The WIRE_TYPE_MAP registry MUST be keyed by the wire type value the registered class emits — its declared type_ default, or, absent one, its class name with the as_ prefix removed, matching what VM-03-001 assigns at construction. A class MUST NOT be registered under its class name when that name is not the type value it emits. Exactly one class MUST own a given type value: a class that shares another's type value MUST declare itself an alias (_wire_type_alias = True) and MUST NOT hold a WIRE_TYPE_MAP key, while remaining reachable by class name through VOCABULARY (VM-01-004). The sole exception is a sanctioned shadow, enumerated in the verifying ratchet: the five as_Vultron* actor subtypes deliberately replace their base AS2 actor classes as the owner of Person, Organization, Service, Application and Group, so an inbound actor keeps its Vultron extension fields.A key that is no object's type value is unreachable from the wire, and it lets a caller holding a class name resolve a wire class by name coincidence, which ARCH-23-002 forbids — as_VultronPerson formerly registered under VultronPerson, which is also a core type's name. Deriving the key from the emitted value removes both hazards and puts the registry key and the generated JSON-LD term (VM-10-002) on one rule instead of two. |
Depends on: VM-03-001 — The class-name fallback must match the value set_type_from_class_name assigns.Depends on: VM-01-005 — Import-time registration is why the verification must force population first. |
Base Model Configuration
| ID | Priority | Requirement | Related |
|---|---|---|---|
VM-02-001 |
MUST | All wire-layer AS2 Pydantic models MUST inherit model_config from as_Base (or an intermediate class that inherits from as_Base), which provides: (a) a camelCase alias generator, (b) populate_by_name=True, and (c) strict-mode validation. |
|
VM-02-002 |
MUST | The @context field (as_context) MUST use explicit validation_alias="@context" and serialization_alias="@context" because @ is not a valid Python identifier character and cannot be handled by the camelCase alias generator |
Refines: VM-02-001 |
VM-02-003 |
MUST | Wire-layer models MUST provide to_json(), to_dict(), and from_json() convenience methods that exclude None values and serialize using aliases by default |
Type Auto-Inference
| ID | Priority | Requirement | Related |
|---|---|---|---|
VM-03-001 |
MUST | The as_type field on every concrete wire-layer class MUST be set automatically at construction time if not supplied by the caller |
|
VM-03-002 |
MUST | A wire-layer class that presents its own class-name-derived AS2 type value MUST narrow the type_ field to a Literal[...] matching that value, unless VM-01-007's exemption set names it (an abstract root such as as_Object, which must stay unregistered so an unresolved inline object is left to it). A semantic alias that must not be reachable under its own class name — the _XxxActivity pattern, which presents an ancestor's type — MUST NOT declare its own type_ annotation, because any such declaration registers the class under its class name.Auto-registration reads an own type_ annotation as its test for "is this class concrete" and keys the entry on the class name (VM-01-001), while VM-03-001 fills type_ from the class name whenever a class inherits no default — so a class with neither an annotation nor an inherited default presents a distinct wire type and registers nothing. That is how the AS2 collection types went unregistered until #3242: OrderedCollection was in neither wire registry, so the lookup fell through to the core map and answered a wire caller with a core class. As a MUST, the annotation becomes the single declaration of "distinct wire type", which is what VM-01-007 can then check. The prohibition is scoped to unaddressable aliases: classes such as as_VultronPerson present an ancestor's type but narrow type_ on purpose, so that they register under their own class name. |
Refines: VM-03-001 Refines: VM-01-001 |
VM-03-003 |
MUST NOT | as_type values MUST NOT start with "as_" in serialized wire output |
Constrains: VM-03-001 |
ID Generation
| ID | Priority | Requirement | Related |
|---|---|---|---|
VM-04-001 |
MUST | All wire-layer objects MUST have a globally unique as_id field set at construction time by generate_new_id() if not supplied |
Implements: OID-01-001 |
Vocabulary Extension
Rehydration
| ID | Priority | Requirement | Related |
|---|---|---|---|
VM-06-001 |
MUST | Before any wire-layer activity is passed to semantic extraction, callers MUST attempt to rehydrate all nested URI string references into fully typed objects via rehydrate(obj, dl) |
Refines: SE-01-002 — semantic-extraction.md |
VM-06-002 |
MUST | rehydrate() MUST receive the active DataLayer instance via the dl parameter |
|
VM-06-003 |
MUST | Rehydration MUST be recursive: if a rehydrated object itself contains URI string references in its nested fields, those MUST also be rehydrated up to MAX_REHYDRATION_DEPTH levels |
|
VM-06-004 |
MUST | If a URI string reference cannot be resolved (object not in DataLayer), rehydration MUST: (a) leave the field as the original URI string value, (b) log a WARNING identifying the unresolvable URI, and (c) continue rehydrating remaining fields rather than raising an exception. | Implements: SE-01-003 — semantic-extraction.md |
VM-06-005 |
MUST | If a rehydrated object's as_type is not found in the vocabulary registry, rehydration MUST raise KeyError or ValueError rather than returning a partially constructed object |
|
VM-06-006 |
MUST | A vocabulary override (replacing the default VultronVocabulary with a custom one) MUST preserve at least one registration from each base module that the production codebase depends on. An override that silently clears all base registrations is equivalent to a blank vocabulary and will cause rehydration failures for all standard Vultron activity types.The vocabulary registry is global. Test or demo code that installs a minimal override without re-registering base types causes downstream rehydration to raise KeyError for every standard type. The invariant ensures overrides are additive-by-default. |
Refines: VM-06-001 |
VM-06-007 |
MUST | rehydrate() in vultron/wire/as2/rehydration.py is the sole owner of ID-to-object materialisation. When a slot's declared type admits a model but admits no str branch — as_CaseStatus, list[as_Activity] — a bare IRI is not a value it can hold, so rehydrate() MUST resolve that IRI through the supplied DataLayer and MUST refuse with a VultronReferenceResolutionError (a ValueError subclass) naming the field and the IRI when it cannot. Refusal covers both failure modes: the IRI resolving to nothing, and the IRI resolving to an object that is not an AS2 object and therefore cannot occupy a wire slot — which is the normal dl.read() result for a paired type while DL-05-004 returns core objects. It MUST NOT fabricate a placeholder object, MUST NOT synthesize a value for any field the placeholder would have required, and MUST NOT substitute a shape the slot cannot hold. Only slots whose declared type is an as_Object subclass are in scope: a core dimension field admits no str either but serializes to a bare state value (SDO-01-004), not to a reference. Slots that do admit str (every ActivityStreamRef[T]) are out of scope here and keep VM-06-004's defer behaviour.ARCH-25-009 requires object slots to hold whole objects, and ADR-0099 as first written named nothing to fill them. Per-class from_core did it by fabricating a stub with an invented actor — the case owner, or the case's own URI — which misattributes every activity the owner did not perform. rehydrate() already holds the DataLayer (VM-06-002), so it can resolve the real object and read the real actor off it. Naming it keeps the behaviour alive when the projection methods are deleted. |
Refines: VM-06-001 Depends on: VM-06-002 — Materialisation needs the dl parameter that requirement mandates. |
VM-06-008 |
MUST | A wire-branch caller resolving an AS2 type value MUST use a lookup that returns only classes the wire registry holds: as_Base subclasses, or classes registered in WIRE_TYPE_MAP as their own wire form (ADR-0099). The core-type-map fallback (ARCH-12-010) MUST be reachable only from a caller that has asked for it explicitly; it MUST NOT be the default answer a wire-branch caller receives. A caller that may legitimately receive either branch — the persistence read paths that reconstruct whatever was stored — MUST state that in its call rather than inherit it from the function's default.A core-map hit from a wire-branch caller is a coincidence of naming, not a wire counterpart (ARCH-23-002). A filter in one caller (#3232) did not protect the others: the inbox re-parse path persisted a core class for an inbound OrderedCollection. A permissive default hands the unsafe answer to every new caller. Registry membership, not as_Base ancestry, is the test: the canonical VulnerabilityCase is a core class registered as its own wire form (ADR-0099). |
Refines: MV-04-003 Constrains: ARCH-12-010 — ARCH-12-010's registration duty stands; what changes is which callers reach the map it fills. |
Serialization Rules
| ID | Priority | Requirement | Related |
|---|---|---|---|
VM-07-001 |
MUST | Serialized wire-layer objects MUST exclude None fields and MUST exclude empty string fields |
|
VM-07-003 |
MUST NOT | object_to_record() MUST NOT be called on an object whose as_type starts with "as_" |
|
VM-07-004 |
MUST | serialize_as_any=True MUST be declared on any Pydantic model field that holds an object whose concrete runtime type is a subtype of the field's declared type, at both the persistence serialization boundary and the wire delivery serialization boundary. Omitting serialize_as_any=True at either boundary causes the serializer to use the declared (base) type's schema, silently dropping all subtype-specific fields.Pydantic v2 uses the declared field type for serialization by default. A field typed as as_Object but holding an as_VulnerabilityCaseStub at runtime will serialize using as_Object's schema, dropping the stub's own fields (caseId, activeEmbargo, caseStatus) entirely with no error. Root causes of BUG-26042201 and BUG-26051902. Both persistence and delivery boundaries are required because a correct write-path can still produce incorrect wire output if the delivery serializer also lacks this flag. |
Refines: VM-07-001 |
Static Object Integrity
| ID | Priority | Requirement | Related |
|---|---|---|---|
VM-08-002 |
MUST | A received wire Activity MUST carry, as its received evidence, the JSON body that arrived, as decoded and re-serialized by the parser before anything expands, validates, or hydrates it, and held independently of the parsed object graph. The evidence MUST NOT change when the parsed activity, any object nested in it, or the caller's body is mutated. If a more hydrated form is required for routing or use-case execution, it MUST be produced as a separate object, and it MUST carry the same evidence unchanged. A replay of a deferred activity, or a load of an archived one, is parsed again from the received body held for it, and carries that body as its evidence. A resend of an archived activity with the same body is routed as the archived copy, parsed from the body intake archived, and carries that body, the first delivery's, as its evidence. An activity routed or rebuilt from anything other than a delivery's sealed body (a resend of the held body under the id of a still-deferred activity, routed as the held copy before its replay, or an activity the receiver built itself and holds as a stored row) carries no received evidence, and it MUST NOT be given the evidence of a different delivery. The received artifact is the evidence of what arrived, and the ledger replicates it via Announce(CaseLedgerEntry). A class-level frozen=True cannot hold it: frozen does not reach nested models, and since ARCH-25-003 the nested objects are mutable core classes. The evidence is therefore a value taken at parse, not a property of the graph. The deferred record and intake's archive keep the sealed body (IO-03-004, CLP-10-017), so a replay carries the first delivery's evidence and a load sees the body that arrived (ADR-0149). A resend is another delivery, so its own body never becomes the evidence of the copy routed in its place: that would claim the wrong event. The archived copy routed for a resend is the first delivery's sealed body, so carrying it as evidence claims exactly the event that arrived first; the repeat guards then decide, so a committed act is a repeat and an act that never committed, such as a failed one, runs as a first delivery and records that body (ADR-0151, #4981). A resend of a different body under a held id is refused, not routed (IE-10-002), so an id's evidence has one answer. |
Refines: VM-08-001 |
VM-08-004 |
MUST | An emitted wire Activity blob MUST be produced by the factory as a complete, frozen object before being handed to any port or adapter. The exact blob returned to core MUST be used as both the delivery payload and CaseLedgerEntry.payloadSnapshot. No port or adapter MAY enrich, mutate, or re-derive the blob after it leaves the factory.How this codebase meets VM-08-003: the factory is solely responsible for completeness, and ports relay only. Split from VM-08-003 so it states only what participants can observe (MS-12-010). |
Implements: VM-08-003 |
Vultron Semantic–AS2 Mapping
Defines the authoritative mapping from each Vultron semantic message type (MessageSemantics enum value) to its ActivityStreams 2.0 wire representation. This is the single source of truth for how Vultron domain semantics are encoded as AS2 activity structures, isolating transport and wire-encoding concerns from the core domain logic and case-state machines.
File ID: VAM
General Mapping Conventions
| ID | Priority | Requirement | Related |
|---|---|---|---|
VAM-01-001 |
MUST | Every MessageSemantics enum value except the UNKNOWN and UNKNOWN_UNRESOLVABLE_OBJECT sentinels MUST have exactly one entry in SEMANTIC_REGISTRY carrying a non-None pattern that defines its AS2 wire representation |
|
VAM-01-002 |
MUST | The AS2 activity patterns defined in this document MUST be implemented as ActivityPattern objects in vultron/wire/as2/extractor/_instances.py. |
|
VAM-01-002b |
MUST NOT | AS2 activity pattern objects MUST NOT be defined in any module other than vultron/wire/as2/extractor/_instances.py. |
Refines: VAM-01-002 |
VAM-01-003 |
MUST | Pattern entries in SEMANTIC_REGISTRY MUST be ordered from most-specific to least-specific so that find_matching_semantics() returns the correct semantic for overlapping patterns |
|
VAM-01-004 |
MUST NOT | An AS2 activity MUST be rehydrated (URI string references expanded to full objects via the DataLayer) before pattern matching; pattern matching MUST NOT rely on unhydrated string fields for type discrimination | |
VAM-01-005 |
MUST | When a pattern specifies a nested activity as the object field, the outer activity's object MUST be a fully rehydrated AS2 activity object matching the inner ActivityPattern |
|
VAM-01-006 |
MUST | Pattern matching MUST be conservative when a non-object_ field is a non-rehydratable URI string: such a field MUST be treated as matching any expected type rather than failing the matchPeripheral reference fields ( actor, target, context, etc.) do not drive semantic dispatch; conservatively matching them avoids false negatives when the DataLayer cannot expand every reference |
|
VAM-01-009 |
MUST | If the object_ field of an activity remains a bare string URI after rehydration (i.e., the DataLayer does not have a record for that URI), find_matching_semantics() MUST return MessageSemantics.UNKNOWNSemantic dispatch depends on the object type. A bare string in object_ makes the object type unknowable; proceeding as if the pattern matches would route the activity to the wrong handler |
|
VAM-01-007 |
MUST | MessageSemantics.UNKNOWN MUST be returned when no registered pattern matches the incoming activity |
|
VAM-01-010 |
MUST | This codebase MUST send a CaseStatus or a ParticipantStatus inline on the single Add that attaches it (VAM-08-002, VAM-08-004), MUST send a Note inline on the single Add(Note) that attaches it (VAM-07-002), and MUST send a proposed EmbargoEvent inline on the Invite that proposes it (VAM-05-005). An inbox MUST refuse a received Create(CaseStatus), Create(ParticipantStatus) or Create(Note) as an unrecognized activity, and MUST report a received Create(CaseParticipant) or Create(EmbargoEvent), each the CASE_MANAGER's own record of a creation (VAM-05-016), as "no use case found" (SE-05-002, ADR-0140). Only a Create whose object is an EmbargoEvent is recognized: a Create of any other Event is refused as an unrecognized activity.VAM-01-011 makes the single message the only protocol form and leaves how a receiver handles a stray Create to the receiver; this item is this codebase's answer. The removed Create routes stored their object with no ledger entry, which CLP-07-013 rules out (#4598), and the removed Create(Note) route made a second form for attaching a note (#4707). Create(EmbargoEvent) came back as the CASE_MANAGER's ledger record of an embargo its case's creation wrote (#5122), which a replica copies from the ledger and no inbox handles. The inline rule binds what this codebase sends: a received Invite may still name its terms by URI (ADR-0123). |
Refines: VAM-01-011 — this codebase's inline sending and refusal of the Create forms Implements: VAM-08-002 Implements: VAM-08-004 Implements: VAM-05-005 Implements: VAM-07-002 |
Embargo Management Messages
| ID | Priority | Requirement | Related |
|---|---|---|---|
VAM-05-010 |
MUST | This codebase MUST carry the proposed EmbargoEvent inline as the object of the Accept(Event)[target=VulnerabilityCase] of VAM-05-008 and of the Reject(Event)[target=VulnerabilityCase] of VAM-05-009.How this codebase meets VAM-05-008 and VAM-05-009; split from them so they state only what other participants observe (MS-12-010). This codebase matches the semantic on the typed inline object and does not yet dereference (AKM-05-003), so the event travels inline. That is a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: VAM-05-008 Implements: VAM-05-009 |
VAM-05-012 |
MUST | This codebase MUST carry the EmbargoEvent in force inline as the object of the Leave(Event)[context=VulnerabilityCase] of VAM-05-011.How this codebase meets VAM-05-011; split from it so it states only what other participants observe (MS-12-010). This codebase matches the semantic on the typed inline object and does not yet dereference (AKM-05-003), so the event travels inline. That is a shortcut of this prototype reference implementation, not a protocol rule. |
Implements: VAM-05-011 |